mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-18 04:21:00 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66623693df | ||
|
|
e2ab2eb603 | ||
|
|
87bdfc5e91 | ||
|
|
78fe589fcf | ||
|
|
bcd09af2d6 | ||
|
|
de97ad6b50 | ||
|
|
417841b3d2 | ||
|
|
11f8631929 | ||
|
|
d650247817 | ||
|
|
46df27e8c6 | ||
|
|
bb75bc9400 | ||
|
|
8acdfc216d | ||
|
|
16e7b7e6e5 | ||
|
|
0f46b20fc4 | ||
|
|
3654161245 | ||
|
|
d691fc837e | ||
|
|
b9af8fc051 | ||
|
|
e14d90e37e | ||
|
|
71ec298db5 | ||
|
|
53cf4bc3c5 | ||
|
|
0f9a4cffd2 | ||
|
|
105d223be4 | ||
|
|
7a54a83c8d | ||
|
|
c52267bbb6 | ||
|
|
e93556e537 | ||
|
|
391cfbc50c | ||
|
|
608da9ce1c | ||
|
|
b35174f857 | ||
|
|
ae18cd1a12 | ||
|
|
68dd9fc173 | ||
|
|
f95720758e | ||
|
|
d4c07fdc8e | ||
|
|
28fa65df9d | ||
|
|
c52edb75df | ||
|
|
11fe75dd57 | ||
|
|
ec107ca0ce | ||
|
|
7172d4da90 | ||
|
|
badcbfdb17 | ||
|
|
748d5c0939 | ||
|
|
dd7426a01f | ||
|
|
fde60b7d68 | ||
|
|
297e5ca92b | ||
|
|
405b2e605e | ||
|
|
1f94641dee | ||
|
|
9249b84ddd | ||
|
|
a7cd021ff2 | ||
|
|
8424e97cf8 | ||
|
|
f128eff8de | ||
|
|
db1a0ad5f5 | ||
|
|
8e1321d251 | ||
|
|
ef0829aaa4 | ||
|
|
4d393e17ab | ||
|
|
d35f7bfe53 | ||
|
|
b91b0e8796 | ||
|
|
233f1779d8 | ||
|
|
0cebfcf1f5 | ||
|
|
fb3f834c36 | ||
|
|
30d0725331 | ||
|
|
3140d9e143 | ||
|
|
db6259f8a3 | ||
|
|
49a586db53 | ||
|
|
aa2149e2cf |
@@ -29,6 +29,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Needed to get all tags for versioning
|
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
|
# Set version information
|
||||||
- name: Set Version
|
- name: Set Version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Needed to get all tags for versioning
|
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
|
# Set version information
|
||||||
- name: Set Version
|
- name: Set Version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -27,6 +27,26 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
+3
-14
@@ -43,20 +43,7 @@ node_modules/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Ignore all Go files in the components directory
|
# Ignore all Go files in the components directory
|
||||||
components/*.go
|
*_templ.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
|
# Ignore the data directory
|
||||||
data/
|
data/
|
||||||
@@ -79,6 +66,8 @@ backups/
|
|||||||
/destination/
|
/destination/
|
||||||
/archive/
|
/archive/
|
||||||
|
|
||||||
|
# Ignore the dist directory
|
||||||
|
static/dist/
|
||||||
|
|
||||||
# Ignore binaries
|
# Ignore binaries
|
||||||
gomft
|
gomft
|
||||||
+44
-3
@@ -1,3 +1,32 @@
|
|||||||
|
# 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
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -17,7 +46,20 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
# Generate template files from .templ files
|
# Generate template files from .templ files
|
||||||
@@ -61,8 +103,7 @@ RUN addgroup -g ${GID} ${USERNAME} && \
|
|||||||
COPY --from=builder /app/gomft /app/
|
COPY --from=builder /app/gomft /app/
|
||||||
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
||||||
|
|
||||||
# Copy static files and configurations
|
# Copy components
|
||||||
COPY static/ /app/static/
|
|
||||||
COPY components/ /app/components/
|
COPY components/ /app/components/
|
||||||
|
|
||||||
# Copy entrypoint script
|
# Copy entrypoint script
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
- SFTP
|
- SFTP
|
||||||
- FTP
|
- FTP
|
||||||
- SMB/CIFS shares
|
- SMB/CIFS shares
|
||||||
|
- Hetzner Storage Box
|
||||||
|
- Backblaze B2
|
||||||
|
- Wasabi
|
||||||
- Local filesystem
|
- Local filesystem
|
||||||
- And more via rclone
|
- And more via rclone
|
||||||
- **Webhook Notifications**: Receive real-time notifications of job events:
|
- **Webhook Notifications**: Receive real-time notifications of job events:
|
||||||
@@ -52,6 +55,15 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
- Custom HTTP headers
|
- Custom HTTP headers
|
||||||
- Selectable events (job success, job failure)
|
- Selectable events (job success, job failure)
|
||||||
- Detailed JSON payload with job information
|
- 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
|
- **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
|
- **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:
|
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
|
||||||
@@ -77,6 +89,14 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
|||||||
- **Transfer Configurations**: Full control over source and destination connection parameters
|
- **Transfer Configurations**: Full control over source and destination connection parameters
|
||||||
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
|
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
|
||||||
- **Security**: Role-based access control with admin-managed user accounts and secure password management
|
- **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
|
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
|
||||||
- **User Profile Management**: Personal settings including theme preferences
|
- **User Profile Management**: Personal settings including theme preferences
|
||||||
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
|
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
|
||||||
@@ -294,6 +314,10 @@ PGID=1000
|
|||||||
- If longer than 32 bytes, it will be truncated to 32 bytes
|
- If longer than 32 bytes, it will be truncated to 32 bytes
|
||||||
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
|
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
|
||||||
|
|
||||||
|
|
||||||
|
- SSL/TLS Verification Control:
|
||||||
|
- `SKIP_SSL_VERIFY`: Set to `true` to disable SSL/TLS certificate verification for outgoing connections (e.g., webhooks, email). Use with caution, as this can expose connections to man-in-the-middle attacks. Defaults to `false` (verification enabled).
|
||||||
|
- Example: `SKIP_SSL_VERIFY=true`
|
||||||
### Logging Configuration
|
### Logging Configuration
|
||||||
|
|
||||||
GoMFT provides configurable logging with rotation support through the following environment variables:
|
GoMFT provides configurable logging with rotation support through the following environment variables:
|
||||||
@@ -355,7 +379,41 @@ Log files contain detailed information about file transfers, job execution, and
|
|||||||
- Choose notification triggers (job success, job failure, or both)
|
- Choose notification triggers (job success, job failure, or both)
|
||||||
- Test your webhook integration with manual job runs
|
- Test your webhook integration with manual job runs
|
||||||
|
|
||||||
8. Manage file metadata:
|
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
|
- Navigate to the "Files" section to view all processed files
|
||||||
- Use filters to quickly find files by status, job ID, or filename
|
- 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
|
- Click on any file to view detailed metadata including timestamps, size, and hash
|
||||||
@@ -363,7 +421,7 @@ Log files contain detailed information about file transfers, job execution, and
|
|||||||
- Delete file metadata records when no longer needed
|
- Delete file metadata records when no longer needed
|
||||||
- View files associated with specific jobs by navigating from the job details
|
- View files associated with specific jobs by navigating from the job details
|
||||||
|
|
||||||
9. Utilize admin tools (administrators only):
|
11. Utilize admin tools (administrators only):
|
||||||
- Access the "Admin Tools" section from the navigation menu
|
- Access the "Admin Tools" section from the navigation menu
|
||||||
- View system statistics and server information
|
- View system statistics and server information
|
||||||
- Create and manage database backups
|
- Create and manage database backups
|
||||||
@@ -373,19 +431,57 @@ Log files contain detailed information about file transfers, job execution, and
|
|||||||
|
|
||||||
### User Management
|
### 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
|
- **Administrators**: Can create and manage users, access all features
|
||||||
- **Regular Users**: Can manage transfers and view history
|
- **Regular Users**: Can manage transfers and view history
|
||||||
|
|
||||||
User management features:
|
#### Authentication Options
|
||||||
- Only administrators can create new user accounts
|
|
||||||
- User passwords are securely hashed with bcrypt
|
1. **Built-in Authentication**:
|
||||||
- Password history tracking prevents reuse of recent passwords
|
- Email/password login with secure password hashing
|
||||||
- Account lockout after multiple failed login attempts
|
- JWT-based session management
|
||||||
- Self-service password reset via secure email links
|
- Password history tracking
|
||||||
- JWT-based authentication with tokens
|
- Account lockout protection
|
||||||
- User theme preference settings (light/dark)
|
- 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
|
### Two-Factor Authentication (2FA) Implementation
|
||||||
|
|
||||||
@@ -432,6 +528,10 @@ The following fields have been added to the `users` table:
|
|||||||
- Local filesystem
|
- Local filesystem
|
||||||
- Amazon S3
|
- Amazon S3
|
||||||
- MinIO (S3-compatible storage)
|
- MinIO (S3-compatible storage)
|
||||||
|
- NextCloud
|
||||||
|
- Backblaze B2
|
||||||
|
- Wasabi
|
||||||
|
- Hetzner Storage Box
|
||||||
- SFTP
|
- SFTP
|
||||||
- FTP
|
- FTP
|
||||||
- SMB/CIFS shares
|
- SMB/CIFS shares
|
||||||
@@ -479,13 +579,16 @@ The following fields have been added to the `users` table:
|
|||||||
- Manual execution
|
- Manual execution
|
||||||
- Enable/disable schedules
|
- Enable/disable schedules
|
||||||
|
|
||||||
8. **Webhook Notifications**:
|
8. **Notification Options**:
|
||||||
- **Webhook Integration**: Send notifications to external systems when jobs complete
|
- **Email Notifications**: Receive job status updates via email
|
||||||
- **Secure Authentication**: HMAC-SHA256 signature for webhook verification
|
- **Webhook Notifications**: Integration with external systems
|
||||||
- **Custom Headers**: Add custom HTTP headers to webhook requests
|
- **Pushbullet**: Push notifications to your devices
|
||||||
- **Flexible Configuration**: Configure different webhooks for different jobs
|
- **Ntfy**: Simple push notifications via ntfy.sh
|
||||||
- **Event Selection**: Choose to send notifications on success, failure, or both
|
- **Gotify**: Self-hosted notification server integration
|
||||||
- **Detailed Payload**: Rich JSON payload with complete job execution details
|
- **Pushover**: Professional notification service
|
||||||
|
- Configure event triggers (start, complete, error)
|
||||||
|
- Customize notification message templates
|
||||||
|
- Selective notification based on job status
|
||||||
|
|
||||||
### Email Notifications
|
### Email Notifications
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminLayout renders the main admin dashboard
|
||||||
|
templ AdminLayout(ctx context.Context) {
|
||||||
|
@LayoutWithContext("Administration", ctx) {
|
||||||
|
<div class="flex flex-col md:flex-row bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<!-- Sidebar Navigation -->
|
||||||
|
<nav class="md:w-64 md:min-h-screen bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="p-4 overflow-y-auto">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Admin Console</h2>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="/admin" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white bg-gray-100 dark:bg-gray-700 group">
|
||||||
|
<i class="fas fa-tachometer-alt w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/users" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-users w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
User Management
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/roles" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-user-shield w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Role Management
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/audit" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-clipboard-list w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Audit Logs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/database" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-database w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
Database Tools
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/settings" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||||
|
<i class="fas fa-cog w-5 h-5 text-gray-500 dark:text-gray-400 mr-3"></i>
|
||||||
|
System Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex-1 p-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Users</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">1,234</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-100 p-3 rounded-full dark:bg-blue-900">
|
||||||
|
<i class="fas fa-users text-blue-500 w-6 h-6 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Active Sessions</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">42</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
|
||||||
|
<i class="fas fa-signal text-green-500 w-6 h-6 dark:text-green-300"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">System Health</p>
|
||||||
|
<p class="text-2xl font-bold text-green-500 dark:text-green-400">98%</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-100 p-3 rounded-full dark:bg-green-900">
|
||||||
|
<i class="fas fa-heartbeat text-green-500 w-6 h-6 dark:text-green-300"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity Table -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Activity</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">User</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Action</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<!-- Example Rows -->
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">admin@example.com</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">Updated user permissions</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">2024-02-20 14:32:10</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoleFormData struct {
|
||||||
|
Role *Role
|
||||||
|
IsNew bool
|
||||||
|
ErrorMessage string
|
||||||
|
AllPermissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AdminRoleForm(ctx context.Context, data RoleFormData) {
|
||||||
|
@LayoutWithContext(getFormTitle(data.IsNew), ctx) {
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex" aria-label="Breadcrumb">
|
||||||
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||||
|
<li class="inline-flex items-center">
|
||||||
|
<a href="/admin" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 dark:text-gray-400 dark:hover:text-white">
|
||||||
|
<i class="fas fa-shield-alt w-4 h-4 mr-2"></i>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
|
||||||
|
<a href="/admin/roles" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 dark:text-gray-400 dark:hover:text-white">Roles</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li aria-current="page">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-chevron-right w-3 h-3 text-gray-400 mx-1"></i>
|
||||||
|
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2 dark:text-gray-400">
|
||||||
|
if data.IsNew {
|
||||||
|
New Role
|
||||||
|
} else {
|
||||||
|
Edit Role
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if data.ErrorMessage != "" {
|
||||||
|
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400" role="alert">
|
||||||
|
<span class="font-medium">Error!</span> { data.ErrorMessage }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<form method="POST" action={ getFormAction(data.IsNew, data.Role) }>
|
||||||
|
if !data.IsNew {
|
||||||
|
<input type="hidden" name="_method" value="PUT"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Role Name -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Role Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||||
|
placeholder="Enter role name"
|
||||||
|
required
|
||||||
|
if !data.IsNew {
|
||||||
|
value={ data.Role.Name }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Description -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="3"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
|
||||||
|
placeholder="Enter role description"
|
||||||
|
>
|
||||||
|
if !data.IsNew {
|
||||||
|
{ data.Role.Description }
|
||||||
|
}
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Permissions</label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
for _, perm := range data.AllPermissions {
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={ "perm_" + perm }
|
||||||
|
name="permissions[]"
|
||||||
|
value={ perm }
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
if !data.IsNew && hasPermission(data.Role, perm) {
|
||||||
|
checked
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label for={ "perm_" + perm } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||||
|
{ formatPermissionLabel(perm) }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="flex items-center justify-end space-x-3">
|
||||||
|
<a href="/admin/roles" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
if data.IsNew {
|
||||||
|
Create Role
|
||||||
|
} else {
|
||||||
|
Update Role
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func getFormTitle(isNew bool) string {
|
||||||
|
if isNew {
|
||||||
|
return "New Role"
|
||||||
|
}
|
||||||
|
return "Edit Role"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFormAction(isNew bool, role *Role) templ.SafeURL {
|
||||||
|
if isNew {
|
||||||
|
return "/admin/roles"
|
||||||
|
}
|
||||||
|
return templ.SafeURL("/admin/roles/" + fmt.Sprint(role.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPermission(role *Role, permission string) bool {
|
||||||
|
if role == nil || role.Permissions == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range role.Permissions {
|
||||||
|
if p == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPermissionLabel(permission string) string {
|
||||||
|
// Convert permission strings like "users.create" to "Create Users"
|
||||||
|
parts := strings.Split(permission, ".")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return permission
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := strings.Title(parts[0])
|
||||||
|
action := strings.Title(parts[1])
|
||||||
|
|
||||||
|
return action + " " + resource
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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.GetEnabled() {
|
||||||
|
<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 ""
|
||||||
|
}
|
||||||
@@ -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.GetEnabled() {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.GetEnabled() {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
+428
-167
@@ -7,12 +7,15 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/components/providers/source"
|
"github.com/starfleetcptn/gomft/components/providers/source"
|
||||||
"github.com/starfleetcptn/gomft/components/providers/destination"
|
"github.com/starfleetcptn/gomft/components/providers/destination"
|
||||||
"github.com/starfleetcptn/gomft/components/providers/common"
|
"github.com/starfleetcptn/gomft/components/providers/common"
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConfigFormData struct {
|
type ConfigFormData struct {
|
||||||
Config *db.TransferConfig
|
Config *db.TransferConfig
|
||||||
IsNew bool
|
IsNew bool
|
||||||
|
// Fields for pre-rendering flags on edit
|
||||||
|
InitialCommand *db.RcloneCommand
|
||||||
|
SelectedFlagsMap map[uint]bool
|
||||||
|
SelectedFlagValues map[uint]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConfigFormTitle(isNew bool) string {
|
func getConfigFormTitle(isNew bool) string {
|
||||||
@@ -28,7 +31,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
sourceType := "local"
|
sourceType := "local"
|
||||||
sourcePath := ""
|
sourcePath := ""
|
||||||
sourceHost := ""
|
sourceHost := ""
|
||||||
sourcePort := 22
|
sourcePort := 0 // Initialize to 0 to trigger default setting
|
||||||
sourceUser := ""
|
sourceUser := ""
|
||||||
sourcePassword := ""
|
sourcePassword := ""
|
||||||
sourceKeyFile := ""
|
sourceKeyFile := ""
|
||||||
@@ -47,7 +50,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
sourceTeamDrive := ""
|
sourceTeamDrive := ""
|
||||||
// Google Photos source fields
|
// Google Photos source fields
|
||||||
sourceReadOnly := false
|
sourceReadOnly := false
|
||||||
sourceStartYear, _ := strconv.Atoi(getCurrentYear())
|
sourceStartYear := 2025
|
||||||
sourceIncludeArchived := false
|
sourceIncludeArchived := false
|
||||||
|
|
||||||
filePattern := ""
|
filePattern := ""
|
||||||
@@ -56,7 +59,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destinationType := "local"
|
destinationType := "local"
|
||||||
destinationPath := ""
|
destinationPath := ""
|
||||||
destHost := ""
|
destHost := ""
|
||||||
destPort := 22
|
destPort := 0 // Initialize to 0 to trigger default setting
|
||||||
destUser := ""
|
destUser := ""
|
||||||
destPassword := ""
|
destPassword := ""
|
||||||
destKeyFile := ""
|
destKeyFile := ""
|
||||||
@@ -75,7 +78,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destTeamDrive := ""
|
destTeamDrive := ""
|
||||||
// Google Photos destination fields
|
// Google Photos destination fields
|
||||||
destReadOnly := false
|
destReadOnly := false
|
||||||
destStartYear, _ := strconv.Atoi(getCurrentYear()) // Default to current year int
|
destStartYear := 2025
|
||||||
destIncludeArchived := false
|
destIncludeArchived := false
|
||||||
|
|
||||||
archivePath := ""
|
archivePath := ""
|
||||||
@@ -84,6 +87,8 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
skipProcessedFiles := true
|
skipProcessedFiles := true
|
||||||
maxConcurrentTransfers := 4
|
maxConcurrentTransfers := 4
|
||||||
rcloneFlags := ""
|
rcloneFlags := ""
|
||||||
|
commandId := uint(1) // Default to 'copy' command
|
||||||
|
commandFlags := ""
|
||||||
useBuiltinAuthSource := true
|
useBuiltinAuthSource := true
|
||||||
useBuiltinAuthDest := true
|
useBuiltinAuthDest := true
|
||||||
|
|
||||||
@@ -117,7 +122,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
if config.SourceReadOnly != nil {
|
if config.SourceReadOnly != nil {
|
||||||
sourceReadOnly = *config.SourceReadOnly
|
sourceReadOnly = *config.SourceReadOnly
|
||||||
}
|
}
|
||||||
sourceStartYear = config.SourceStartYear
|
// sourceStartYear = config.SourceStartYear
|
||||||
if config.SourceIncludeArchived != nil {
|
if config.SourceIncludeArchived != nil {
|
||||||
sourceIncludeArchived = *config.SourceIncludeArchived
|
sourceIncludeArchived = *config.SourceIncludeArchived
|
||||||
}
|
}
|
||||||
@@ -152,7 +157,7 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
if config.DestReadOnly != nil {
|
if config.DestReadOnly != nil {
|
||||||
destReadOnly = *config.DestReadOnly
|
destReadOnly = *config.DestReadOnly
|
||||||
}
|
}
|
||||||
destStartYear = config.DestStartYear
|
// destStartYear = config.DestStartYear
|
||||||
if config.DestIncludeArchived != nil {
|
if config.DestIncludeArchived != nil {
|
||||||
destIncludeArchived = *config.DestIncludeArchived
|
destIncludeArchived = *config.DestIncludeArchived
|
||||||
}
|
}
|
||||||
@@ -163,6 +168,8 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
skipProcessedFiles = config.GetSkipProcessedFiles()
|
skipProcessedFiles = config.GetSkipProcessedFiles()
|
||||||
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
||||||
rcloneFlags = config.RcloneFlags
|
rcloneFlags = config.RcloneFlags
|
||||||
|
commandId = config.CommandID
|
||||||
|
commandFlags = config.CommandFlags
|
||||||
if config.UseBuiltinAuthSource != nil {
|
if config.UseBuiltinAuthSource != nil {
|
||||||
useBuiltinAuthSource = *config.UseBuiltinAuthSource
|
useBuiltinAuthSource = *config.UseBuiltinAuthSource
|
||||||
} else if sourceClientId != "" || sourceClientSecret != "" {
|
} else if sourceClientId != "" || sourceClientSecret != "" {
|
||||||
@@ -238,6 +245,8 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
skipProcessedFiles: %v,
|
skipProcessedFiles: %v,
|
||||||
maxConcurrentTransfers: %d,
|
maxConcurrentTransfers: %d,
|
||||||
rcloneFlags: '%s',
|
rcloneFlags: '%s',
|
||||||
|
commandId: %d,
|
||||||
|
commandFlags: '%s',
|
||||||
|
|
||||||
// Path validation states
|
// Path validation states
|
||||||
sourcePathValid: null,
|
sourcePathValid: null,
|
||||||
@@ -245,6 +254,9 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destPathValid: null,
|
destPathValid: null,
|
||||||
destPathError: '',
|
destPathError: '',
|
||||||
|
|
||||||
|
// Command configuration
|
||||||
|
requiresDestination: true,
|
||||||
|
|
||||||
// Methods for path validation
|
// Methods for path validation
|
||||||
checkPath(path, type) {
|
checkPath(path, type) {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
@@ -263,6 +275,69 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
this[type + 'PathValid'] = false;
|
this[type + 'PathValid'] = false;
|
||||||
this[type + 'PathError'] = 'Error checking path: ' + error.message;
|
this[type + 'PathError'] = 'Error checking path: ' + error.message;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Method to check if destination is required based on command type
|
||||||
|
updateCommandRequirements() {
|
||||||
|
// List of commands that don't require destination
|
||||||
|
const listingCommands = ['ls', 'lsd', 'lsl', 'lsf', 'lsjson', 'listremotes'];
|
||||||
|
const infoCommands = ['md5sum', 'sha1sum', 'size', 'version'];
|
||||||
|
const dirCommands = ['mkdir', 'rmdir', 'rmdirs'];
|
||||||
|
const destructiveCommands = ['delete', 'purge'];
|
||||||
|
const specialSinglePathCommands = ['obscure'];
|
||||||
|
|
||||||
|
// Get the command name from the command ID
|
||||||
|
// This will need coordination with your backend to ensure the IDs match the commands
|
||||||
|
let commandName = '';
|
||||||
|
switch(parseInt(this.commandId)) {
|
||||||
|
// Correct mapping based on internal/db/migrations/009_add_rclone_tables.go
|
||||||
|
case 1: commandName = 'copy'; break;
|
||||||
|
case 2: commandName = 'sync'; break;
|
||||||
|
case 3: commandName = 'bisync'; break;
|
||||||
|
case 4: commandName = 'move'; break;
|
||||||
|
case 5: commandName = 'delete'; break;
|
||||||
|
case 6: commandName = 'purge'; break;
|
||||||
|
case 7: commandName = 'mkdir'; break;
|
||||||
|
case 8: commandName = 'rmdir'; break;
|
||||||
|
case 9: commandName = 'rmdirs'; break;
|
||||||
|
case 10: commandName = 'check'; break;
|
||||||
|
case 11: commandName = 'ls'; break;
|
||||||
|
case 12: commandName = 'lsd'; break;
|
||||||
|
case 13: commandName = 'lsl'; break;
|
||||||
|
case 14: commandName = 'lsf'; break;
|
||||||
|
case 15: commandName = 'lsjson'; break;
|
||||||
|
case 16: commandName = 'md5sum'; break;
|
||||||
|
case 17: commandName = 'sha1sum'; break;
|
||||||
|
case 18: commandName = 'size'; break;
|
||||||
|
case 19: commandName = 'version'; break;
|
||||||
|
case 20: commandName = 'cleanup'; break;
|
||||||
|
case 21: commandName = 'dedupe'; break;
|
||||||
|
case 22: commandName = 'copyto'; break;
|
||||||
|
case 23: commandName = 'moveto'; break;
|
||||||
|
case 24: commandName = 'listremotes'; break;
|
||||||
|
case 25: commandName = 'obscure'; break;
|
||||||
|
case 26: commandName = 'cryptcheck'; break;
|
||||||
|
default: commandName = 'copy'; // Default to copy if ID is unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Command ID:', this.commandId, 'Command Name:', commandName);
|
||||||
|
|
||||||
|
// Check if the command requires a destination
|
||||||
|
if (
|
||||||
|
listingCommands.includes(commandName) ||
|
||||||
|
infoCommands.includes(commandName) ||
|
||||||
|
(dirCommands.includes(commandName) && !this.rcloneFlags.includes('--dst')) ||
|
||||||
|
destructiveCommands.includes(commandName) ||
|
||||||
|
specialSinglePathCommands.includes(commandName) ||
|
||||||
|
commandName === 'version' ||
|
||||||
|
commandName === 'listremotes'
|
||||||
|
) {
|
||||||
|
this.requiresDestination = false;
|
||||||
|
console.log('Destination not required for command:', commandName);
|
||||||
|
} else {
|
||||||
|
this.requiresDestination = true;
|
||||||
|
console.log('Destination required for command:', commandName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
|
name, sourceType, sourcePath, sourceHost, sourcePort, sourceUser, sourcePassword, sourceKeyFile, sourceAuthType,
|
||||||
@@ -275,171 +350,357 @@ func getInitialData(config *db.TransferConfig) string {
|
|||||||
destClientId, destClientSecret, destDriveId, destTeamDrive,
|
destClientId, destClientSecret, destDriveId, destTeamDrive,
|
||||||
destReadOnly, destStartYear, destIncludeArchived,
|
destReadOnly, destStartYear, destIncludeArchived,
|
||||||
useBuiltinAuthSource, useBuiltinAuthDest,
|
useBuiltinAuthSource, useBuiltinAuthDest,
|
||||||
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags)
|
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags,
|
||||||
|
commandId, commandFlags)
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||||
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
|
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
|
||||||
<div class="flex flex-col items-center justify-center min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12 px-4 sm:px-6 lg:px-8">
|
<!-- Main Content -->
|
||||||
<div class="max-w-3xl w-full bg-white dark:bg-secondary-800 rounded-lg shadow-md p-8 border border-secondary-200 dark:border-secondary-700">
|
<section class="py-8 px-4">
|
||||||
<div class="flex justify-center mb-6">
|
<div class="mx-auto max-w-3xl">
|
||||||
<div class="p-4 bg-primary-100 dark:bg-primary-900 rounded-full">
|
<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">
|
||||||
<i class="fas fa-cog text-primary-500 dark:text-primary-400 text-2xl"></i>
|
<!-- Form Header -->
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-cog text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{ getConfigFormTitle(data.IsNew) }
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Configure your file transfer settings using the form below
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Form -->
|
||||||
|
<form
|
||||||
|
class="space-y-6"
|
||||||
|
if data.IsNew {
|
||||||
|
hx-post="/configs"
|
||||||
|
} else {
|
||||||
|
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
|
||||||
|
}
|
||||||
|
hx-target="body"
|
||||||
|
hx-redirect="/configs"
|
||||||
|
hx-boost="true"
|
||||||
|
x-data={ getInitialData(data.Config) }
|
||||||
|
x-init="$nextTick(() => {
|
||||||
|
// Ensure initial form state displays correctly on load
|
||||||
|
sourceType = sourceType || 'local';
|
||||||
|
destinationType = destinationType || 'local';
|
||||||
|
|
||||||
|
// Set default ports based on connection type
|
||||||
|
if (sourcePort === 0 || !sourcePort) {
|
||||||
|
if (sourceType === 'sftp') {
|
||||||
|
sourcePort = 22;
|
||||||
|
} else if (sourceType === 'ftp') {
|
||||||
|
sourcePort = 21;
|
||||||
|
} else if (sourceType === 'hetzner') {
|
||||||
|
sourcePort = 23;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destPort === 0 || !destPort) {
|
||||||
|
if (destinationType === 'sftp') {
|
||||||
|
destPort = 22;
|
||||||
|
} else if (destinationType === 'ftp') {
|
||||||
|
destPort = 21;
|
||||||
|
} else if (destinationType === 'hetzner') {
|
||||||
|
destPort = 23;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize command requirements
|
||||||
|
updateCommandRequirements();
|
||||||
|
})"
|
||||||
|
x-effect="if (sourceType === 'sftp' && (sourcePort === 0 || sourcePort === 21 || sourcePort === 23)) {
|
||||||
|
sourcePort = 22;
|
||||||
|
console.log('Updating source port to 22 for SFTP');
|
||||||
|
} else if (sourceType === 'ftp' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 23)) {
|
||||||
|
sourcePort = 21;
|
||||||
|
console.log('Updating source port to 21 for FTP');
|
||||||
|
} else if (sourceType === 'hetzner' && (sourcePort === 0 || sourcePort === 22 || sourcePort === 21)) {
|
||||||
|
sourcePort = 23;
|
||||||
|
console.log('Updating source port to 23 for Hetzner');
|
||||||
|
} else if (destinationType === 'sftp' && (destPort === 0 || destPort === 21 || destPort === 23)) {
|
||||||
|
destPort = 22;
|
||||||
|
console.log('Updating destination port to 22 for SFTP');
|
||||||
|
} else if (destinationType === 'ftp' && (destPort === 0 || destPort === 22 || destPort === 23)) {
|
||||||
|
destPort = 21;
|
||||||
|
console.log('Updating destination port to 21 for FTP');
|
||||||
|
} else if (destinationType === 'hetzner' && (destPort === 0 || destPort === 22 || destPort === 21)) {
|
||||||
|
destPort = 23;
|
||||||
|
console.log('Updating destination port to 23 for Hetzner');
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- Configuration Details Section -->
|
||||||
|
<div class="p-4 mb-4 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
|
||||||
|
<h3 class="text-lg font-medium text-blue-600 dark:text-blue-400">Configuration Details</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
Give your configuration a descriptive name and set up the source and destination locations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name field -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
x-model="name"
|
||||||
|
required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="My Transfer Configuration"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rclone Command Configuration Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-terminal mr-2 text-blue-500 dark:text-blue-400"></i>Command Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Additonal Rclone Flags -->
|
||||||
|
@common.RcloneFlags(data.Config.CommandID) // Pass current command ID
|
||||||
|
|
||||||
|
<!-- Container for flags, pre-rendered on edit, loaded via HTMX on new/change -->
|
||||||
|
<div id="command-flags-container" class="mt-4">
|
||||||
|
if !data.IsNew && data.InitialCommand != nil {
|
||||||
|
// Pre-render flags if editing and command data is available
|
||||||
|
@common.RcloneCommandFlagsContent(data.InitialCommand, data.SelectedFlagsMap, data.SelectedFlagValues)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Configuration Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-upload mr-2 text-blue-500 dark:text-blue-400"></i>Source Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Source selection -->
|
||||||
|
@common.SourceSelection()
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button"
|
||||||
|
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||||
|
hx-post="/configs/test-connection"
|
||||||
|
hx-include="closest form"
|
||||||
|
hx-vals='{"providerType": "source"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#source-test-spinner">
|
||||||
|
<i class="fas fa-plug mr-1"></i> Test Source
|
||||||
|
<span id="source-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||||
|
</button>
|
||||||
|
<!-- Removed target div, result shown via toast -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source type specific forms -->
|
||||||
|
<template x-if="sourceType === 'local'">
|
||||||
|
@source.LocalSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'sftp'">
|
||||||
|
@source.SFTPSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'ftp'">
|
||||||
|
@source.FTPSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 's3'">
|
||||||
|
@source.S3SourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'b2'">
|
||||||
|
@source.B2SourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'wasabi'">
|
||||||
|
@source.WasabiSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'minio'">
|
||||||
|
@source.MinIOSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'smb'">
|
||||||
|
@source.SMBSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'webdav'">
|
||||||
|
@source.WebDAVSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'nextcloud'">
|
||||||
|
@source.NextCloudSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'gdrive'">
|
||||||
|
@source.GoogleDriveSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'gphotos'">
|
||||||
|
@source.GooglePhotosSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'hetzner'">
|
||||||
|
@source.HetznerSourceForm()
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Pattern Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-filter mr-2 text-blue-500 dark:text-blue-400"></i>File Patterns
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- File pattern fields -->
|
||||||
|
@common.FilePatternFields()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destination Configuration Section (only shown if required) -->
|
||||||
|
<div x-show="requiresDestination" x-transition class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-download mr-2 text-blue-500 dark:text-blue-400"></i>Destination Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Destination selection -->
|
||||||
|
@common.DestinationSelection()
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="button"
|
||||||
|
class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-4 py-2 text-center dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-800"
|
||||||
|
hx-post="/configs/test-connection"
|
||||||
|
hx-include="closest form"
|
||||||
|
hx-vals='{"providerType": "destination"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#dest-test-spinner">
|
||||||
|
<i class="fas fa-plug mr-1"></i> Test Destination
|
||||||
|
<span id="dest-test-spinner" class="htmx-indicator ml-2"><i class="fas fa-spinner fa-spin"></i></span>
|
||||||
|
</button>
|
||||||
|
<!-- Removed target div, result shown via toast -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destination type specific forms -->
|
||||||
|
<template x-if="destinationType === 'local'">
|
||||||
|
@destination.LocalDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'sftp'">
|
||||||
|
@destination.SFTPDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'ftp'">
|
||||||
|
@destination.FTPDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 's3'">
|
||||||
|
@destination.S3DestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'b2'">
|
||||||
|
@destination.B2DestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'wasabi'">
|
||||||
|
@destination.WasabiDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'minio'">
|
||||||
|
@destination.MinIODestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'smb'">
|
||||||
|
@destination.SMBDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'nextcloud'">
|
||||||
|
@destination.NextCloudDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'webdav'">
|
||||||
|
@destination.WebDAVDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'gdrive'">
|
||||||
|
@destination.GoogleDriveDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'gphotos'">
|
||||||
|
@destination.GooglePhotosDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'hetzner'">
|
||||||
|
@destination.HetznerDestinationForm()
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Options Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-cogs mr-2 text-blue-500 dark:text-blue-400"></i>Advanced Options
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Archive options -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Archive Settings</h4>
|
||||||
|
@common.ArchiveOptions()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<a href="/configs"
|
||||||
|
class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
if data.IsNew {
|
||||||
|
Create Configuration
|
||||||
|
} else {
|
||||||
|
Save Changes
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Card -->
|
||||||
|
<div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-lightbulb text-yellow-400 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900 dark:text-white">Configuration Tips</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Configure your file transfer settings carefully for optimal performance. Use file patterns to filter which files are transferred, and consider using archive options to keep track of processed files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<h1 class="mt-2 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ getConfigFormTitle(data.IsNew) }
|
|
||||||
</h1>
|
|
||||||
<p class="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
Set up your file transfer configuration
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="space-y-8"
|
|
||||||
if data.IsNew {
|
|
||||||
hx-post="/configs"
|
|
||||||
} else {
|
|
||||||
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
|
|
||||||
}
|
|
||||||
hx-target="body"
|
|
||||||
hx-redirect="/configs"
|
|
||||||
hx-boost="true"
|
|
||||||
x-data={ getInitialData(data.Config) }
|
|
||||||
x-init="$nextTick(() => {
|
|
||||||
// Ensure initial form state displays correctly on load
|
|
||||||
sourceType = sourceType || 'local';
|
|
||||||
destinationType = destinationType || 'local';
|
|
||||||
sourcePort = sourcePort || 22;
|
|
||||||
destPort = destPort || 22;
|
|
||||||
})"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- Name field -->
|
|
||||||
@common.NameField()
|
|
||||||
|
|
||||||
<!-- Source section -->
|
|
||||||
@common.SourceSelection()
|
|
||||||
|
|
||||||
<!-- Source type specific forms -->
|
|
||||||
<template x-if="sourceType === 'local'">
|
|
||||||
@source.LocalSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'sftp'">
|
|
||||||
@source.SFTPSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'ftp'">
|
|
||||||
@source.FTPSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 's3'">
|
|
||||||
@source.S3SourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'minio'">
|
|
||||||
@source.MinIOSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'smb'">
|
|
||||||
@source.SMBSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'webdav'">
|
|
||||||
@source.WebDAVSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'nextcloud'">
|
|
||||||
@source.NextCloudSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'gdrive'">
|
|
||||||
@source.GoogleDriveSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="sourceType === 'gphotos'">
|
|
||||||
@source.GooglePhotosSourceForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- File pattern fields -->
|
|
||||||
@common.FilePatternFields()
|
|
||||||
|
|
||||||
<!-- Destination section -->
|
|
||||||
@common.DestinationSelection()
|
|
||||||
|
|
||||||
<!-- Destination type specific forms -->
|
|
||||||
<template x-if="destinationType === 'local'">
|
|
||||||
@destination.LocalDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'sftp'">
|
|
||||||
@destination.SFTPDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'ftp'">
|
|
||||||
@destination.FTPDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 's3'">
|
|
||||||
@destination.S3DestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'minio'">
|
|
||||||
@destination.MinIODestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'smb'">
|
|
||||||
@destination.SMBDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'nextcloud'">
|
|
||||||
@destination.NextCloudDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'webdav'">
|
|
||||||
@destination.WebDAVDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'gdrive'">
|
|
||||||
@destination.GoogleDriveDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="destinationType === 'gphotos'">
|
|
||||||
@destination.GooglePhotosDestinationForm()
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Archive options -->
|
|
||||||
@common.ArchiveOptions()
|
|
||||||
|
|
||||||
<!-- Rclone flags -->
|
|
||||||
@common.RcloneFlags()
|
|
||||||
|
|
||||||
<!-- Form actions -->
|
|
||||||
<div class="flex justify-end mt-8">
|
|
||||||
<a href="/configs"
|
|
||||||
class="btn-secondary mr-4">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary">
|
|
||||||
<span class="flex items-center">
|
|
||||||
<i class="fas fa-save mr-2"></i>
|
|
||||||
if data.IsNew {
|
|
||||||
Create Configuration
|
|
||||||
} else {
|
|
||||||
Save Changes
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
<p>Configure your file transfer settings carefully for optimal performance</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+288
-121
@@ -6,55 +6,68 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"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) {
|
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 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">
|
||||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
<!-- Backdrop -->
|
||||||
<div class="px-6 pt-5 pb-3 text-center">
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
<div class="flex justify-center mb-2">
|
<!-- Modal content -->
|
||||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
script hideConfigDialog(id string) {
|
script closeModal(id string) {
|
||||||
document.getElementById(id).classList.add("hidden");
|
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) {
|
script showModal(id string) {
|
||||||
document.getElementById(id).classList.remove("hidden");
|
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) {
|
script triggerConfigDelete(dialogId string, configID uint, configName string) {
|
||||||
// Hide the dialog
|
// Hide the dialog
|
||||||
document.getElementById(dialogId).classList.add("hidden");
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
document.getElementById(dialogId).classList.remove("flex");
|
||||||
|
|
||||||
// Store data in a way that's accessible to event handlers
|
// Store data in a way that's accessible to event handlers
|
||||||
window.lastDeletedConfig = {
|
window.lastDeletedConfig = {
|
||||||
@@ -75,38 +88,82 @@ type ConfigsData struct {
|
|||||||
|
|
||||||
templ Configs(ctx context.Context, data ConfigsData) {
|
templ Configs(ctx context.Context, data ConfigsData) {
|
||||||
@LayoutWithContext("Transfer Configurations", ctx) {
|
@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>
|
<script>
|
||||||
// Debug notification system
|
// Debug notification system
|
||||||
console.log("Configs template loaded, setting up notification system");
|
console.log("Configs template loaded, setting up notification system");
|
||||||
|
|
||||||
// Create a global notyf instance if it doesn't exist yet
|
// Function to create and show a toast
|
||||||
if (!window.notyf) {
|
function showToast(message, type) {
|
||||||
window.notyf = new Notyf({
|
const toastContainer = document.getElementById('toast-container');
|
||||||
duration: 3000,
|
|
||||||
position: {
|
// Create toast element
|
||||||
x: 'right',
|
const toast = document.createElement('div');
|
||||||
y: 'top',
|
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';
|
||||||
types: [
|
toast.role = 'alert';
|
||||||
{
|
|
||||||
type: 'success',
|
// Set toast content based on type
|
||||||
background: '#38c172',
|
let iconClass, bgColorClass, textColorClass;
|
||||||
icon: {
|
|
||||||
className: 'fas fa-check-circle',
|
if (type === 'success') {
|
||||||
tagName: 'i'
|
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') {
|
||||||
type: 'error',
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
background: '#e3342f',
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
icon: {
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
className: 'fas fa-exclamation-circle',
|
} else {
|
||||||
tagName: 'i'
|
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
|
// Show status messages based on URL parameters
|
||||||
@@ -122,12 +179,23 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
if (errorDetails) {
|
if (errorDetails) {
|
||||||
message += ": " + errorDetails;
|
message += ": " + errorDetails;
|
||||||
}
|
}
|
||||||
window.notyf.error(message);
|
showToast(message, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'gdrive_auth_success') {
|
if (status === 'gdrive_auth_success') {
|
||||||
window.notyf.success("Google Drive authentication completed successfully");
|
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
|
// Track all HTMX events for debugging
|
||||||
@@ -143,6 +211,18 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
// This is definitely a delete request - store this information
|
// This is definitely a delete request - store this information
|
||||||
window.isConfigDeleteRequest = true;
|
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
|
// Track HTMX after-request events for config deletion
|
||||||
@@ -175,13 +255,29 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
configName = window.lastDeletedConfig.name;
|
configName = window.lastDeletedConfig.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.notyf.success(`Configuration "${configName}" deleted successfully`);
|
showToast(`Configuration "${configName}" deleted successfully`, 'success');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingConfig = false;
|
window.currentlyDeletingConfig = false;
|
||||||
window.isConfigDeleteRequest = false;
|
window.isConfigDeleteRequest = false;
|
||||||
window.lastDeletedConfig = null;
|
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
|
// Track HTMX error events for config deletion
|
||||||
@@ -218,69 +314,97 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
errorMsg = `Error: ${error.error}`;
|
errorMsg = `Error: ${error.error}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.notyf.error(errorMsg);
|
showToast(errorMsg, 'error');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingConfig = false;
|
window.currentlyDeletingConfig = false;
|
||||||
window.isConfigDeleteRequest = false;
|
window.isConfigDeleteRequest = false;
|
||||||
window.lastDeletedConfig = null;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="py-6">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<i class="fas fa-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
|
<i class="fas fa-exchange-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
Transfer Configurations
|
Transfer Configurations
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/configs/new" class="btn-primary">
|
<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 mr-2"></i>
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
New Configuration
|
New Configuration
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
if len(data.Configs) == 0 {
|
if len(data.Configs) == 0 {
|
||||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
<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-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
<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-folder-open text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<i class="fas fa-exchange-alt text-gray-400 dark:text-gray-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>
|
</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>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="card overflow-hidden">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
for _, config := range data.Configs {
|
for _, config := range data.Configs {
|
||||||
<li>
|
<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="px-4 py-4 sm:px-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<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 }
|
{ config.Name }
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Google Drive Authentication Badge -->
|
<!-- Google Drive Authentication Badge -->
|
||||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
|
<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 mr-1 flex items-center"></i>
|
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
|
||||||
Authentication Required
|
Authentication Required
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Google Drive Authentication Status Indicator -->
|
<!-- Google Drive Authentication Status Indicator -->
|
||||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
|
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
|
<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 mr-1 flex items-center"></i>
|
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
|
||||||
Authenticated
|
Authenticated
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -288,22 +412,31 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
<!-- Google Drive Authentication Button -->
|
<!-- Google Drive Authentication Button -->
|
||||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="btn-warning btn-sm">
|
<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="fab fa-google mr-1"></i>
|
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
|
||||||
Authenticate
|
Authenticate
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
|
<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 mr-1"></i>
|
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</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 -->
|
<!-- Add delete dialog for each configuration -->
|
||||||
@ConfigDialog(
|
@ConfigDialog(
|
||||||
fmt.Sprintf("delete-config-dialog-%d", config.ID),
|
fmt.Sprintf("delete-config-dialog-%d", config.ID),
|
||||||
"Delete Configuration",
|
"Delete Configuration",
|
||||||
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
|
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",
|
||||||
"delete",
|
"delete",
|
||||||
config.ID,
|
config.ID,
|
||||||
@@ -311,26 +444,26 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
)
|
)
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
onclick={ showModal(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||||
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">
|
||||||
<i class="fas fa-trash-alt mr-1"></i>
|
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 sm:flex sm:justify-between">
|
<div class="mt-3 sm:flex sm:justify-between">
|
||||||
<div class="sm:flex">
|
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<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 w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
Source: { config.SourceType }: { config.SourcePath }
|
Source: { config.SourceType }: { config.SourcePath }
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<i class="fas fa-download flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<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 }
|
Destination: { config.DestinationType }: { config.DestinationPath }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-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>
|
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
<p>
|
<p>
|
||||||
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
|
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||||
</p>
|
</p>
|
||||||
@@ -345,22 +478,56 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help Notice -->
|
<!-- Help Section -->
|
||||||
<div class="mt-8 text-center">
|
<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">
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="flex items-start mb-2">
|
||||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
<div class="flex items-center h-5">
|
||||||
Configurations define how files are transferred between systems
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
</p>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<!-- Google Drive Auth Help -->
|
<div class="flex items-start mt-4">
|
||||||
<div class="mt-4 text-center">
|
<div class="flex items-center h-5">
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<i class="fab fa-google-drive w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
<i class="fab fa-google mr-1 text-blue-500 inline-flex items-center"></i>
|
</div>
|
||||||
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
|
<div class="ml-2 text-sm">
|
||||||
</p>
|
<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>
|
</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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+183
-91
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DashboardData struct {
|
type DashboardData struct {
|
||||||
@@ -13,19 +15,44 @@ type DashboardData struct {
|
|||||||
CompletedToday int
|
CompletedToday int
|
||||||
FailedTransfers int
|
FailedTransfers int
|
||||||
Configs map[uint]db.TransferConfig
|
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) {
|
templ Dashboard(ctx context.Context, data DashboardData) {
|
||||||
@LayoutWithContext("Dashboard", ctx) {
|
@LayoutWithContext("Dashboard", ctx) {
|
||||||
<div class="py-6">
|
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<!-- Page Header -->
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<i class="fas fa-tachometer-alt 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-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
Dashboard
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
<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"></span>
|
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
</script>
|
</script>
|
||||||
@@ -33,111 +60,131 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Overview Cards -->
|
<!-- Stats Overview Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||||
<div class="card overflow-hidden">
|
<!-- Active Transfers -->
|
||||||
<div class="p-6 flex items-center">
|
<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="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
|
||||||
|
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
|
||||||
|
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
|
<div class="flex items-center">
|
||||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
<!-- Completed Today -->
|
||||||
<div class="p-6 flex items-center">
|
<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="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
|
||||||
|
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
|
||||||
|
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
|
<div class="flex items-center">
|
||||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
<!-- Failed Transfers -->
|
||||||
<div class="p-6 flex items-center">
|
<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="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
|
||||||
|
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
|
||||||
|
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
|
<div class="flex items-center">
|
||||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<!-- Main Content Area -->
|
||||||
<div class="card">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||||
<div class="card-header">
|
<!-- Recent Jobs Card -->
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
<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
|
Recent Jobs
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="p-4">
|
||||||
if len(data.RecentJobs) == 0 {
|
if len(data.RecentJobs) == 0 {
|
||||||
<div class="py-8 text-center">
|
<!-- Empty state with Flowbite styling -->
|
||||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
|
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||||
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
|
<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>
|
</div>
|
||||||
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
|
||||||
<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">
|
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
|
||||||
<span>Create your first job</span>
|
<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-arrow-right ml-1"></i>
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Create your first job
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="flow-root">
|
<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 {
|
for _, job := range data.RecentJobs {
|
||||||
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
|
<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 space-x-4">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
if job.Status == "completed" {
|
if job.Status == "completed" {
|
||||||
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
|
<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 text-green-600 dark:text-green-300"></i>
|
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</span>
|
</span>
|
||||||
} else if job.Status == "failed" {
|
} 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">
|
<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-times text-red-600 dark:text-red-300"></i>
|
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
|
<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-sync-alt text-blue-600 dark:text-blue-300"></i>
|
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 ms-4">
|
||||||
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
|
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||||
{ getConfigNameForHistory(job, data.Configs) }
|
{ getConfigNameForHistory(job, data.Configs) }
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center mt-1">
|
<div class="flex items-center mt-1">
|
||||||
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
|
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||||
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
|
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||||
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||||
<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">
|
||||||
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">
|
View
|
||||||
<i class="fas fa-eye mr-1"></i>
|
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
|
||||||
View
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6">
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||||
<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">
|
<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-ul mr-2"></i>
|
<i class="fas fa-list w-4 h-4 mr-2"></i>
|
||||||
View all jobs
|
View all jobs
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,42 +192,67 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<!-- Quick Actions Card -->
|
||||||
<div class="card-header">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<i class="fas fa-bolt mr-2 text-primary-500"></i>
|
<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
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body space-y-4">
|
<div class="p-4 space-y-4">
|
||||||
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
|
<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-circle mr-2"></i>
|
<i class="fas fa-plus w-5 h-5 mr-2"></i>
|
||||||
Create New Config
|
Create New Config
|
||||||
</a>
|
</a>
|
||||||
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
|
<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-play-circle mr-2"></i>
|
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
|
||||||
Create New Job
|
Create New Job
|
||||||
</a>
|
</a>
|
||||||
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
|
<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 mr-2"></i>
|
<i class="fas fa-history w-5 h-5 mr-2"></i>
|
||||||
View Transfer History
|
View Transfer History
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- System Status Card -->
|
<!-- System Status Card with Flowbite design -->
|
||||||
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
|
||||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
|
<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">
|
||||||
<div class="space-y-2">
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
|
||||||
<div class="flex items-center justify-between">
|
</div>
|
||||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Server</span>
|
<div class="px-4 py-3">
|
||||||
<span class="badge badge-success">Online</span>
|
<div class="space-y-3">
|
||||||
</div>
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between">
|
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
|
||||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">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="badge badge-success">Running</span>
|
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||||
</div>
|
Online
|
||||||
<div class="flex items-center justify-between">
|
</span>
|
||||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Database</span>
|
</div>
|
||||||
<span class="badge badge-success">Connected</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +260,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+920
-703
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
|||||||
|
package details
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/dialog" // Import dialog package
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils" // Import utils package
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataDetails renders the details view for a file metadata, matching original structure
|
||||||
|
templ FileMetadataDetails(ctx context.Context, data file_metadata.FileMetadataDetailsData) {
|
||||||
|
@components.LayoutWithContext("File Details", ctx) {
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||||
|
|
||||||
|
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
|
File Details: { data.File.FileName }
|
||||||
|
</h1>
|
||||||
|
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||||
|
{ data.File.FileName }
|
||||||
|
</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card content -->
|
||||||
|
<div class="p-4 md:p-5">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- File Information -->
|
||||||
|
<div>
|
||||||
|
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
|
||||||
|
</h6>
|
||||||
|
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||||
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Filename
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ data.File.FileName }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Size
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ utils.FormatFileSize(data.File.FileSize) }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Hash
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
if data.File.FileHash != "" {
|
||||||
|
<span class="font-mono">{ data.File.FileHash }</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(data.File.Status) }>
|
||||||
|
{ data.File.Status }
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Original Path
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-folder mr-2 text-yellow-500"></i>
|
||||||
|
<span>{ data.File.OriginalPath }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Destination Path
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
|
||||||
|
<span>{ data.File.DestinationPath }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing Information -->
|
||||||
|
<div>
|
||||||
|
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
|
||||||
|
</h6>
|
||||||
|
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||||
|
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Job
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
|
{ data.File.Job.Name }
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Processed Time
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="far fa-clock mr-2 text-gray-500"></i>
|
||||||
|
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Creation Time
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
|
||||||
|
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Modification Time
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
|
||||||
|
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
if data.File.Status == "error" && data.File.ErrorMessage != "" {
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Error
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
|
||||||
|
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Record Created
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b dark:border-gray-700">
|
||||||
|
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||||
|
Record Updated
|
||||||
|
</th>
|
||||||
|
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||||
|
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete dialog component call -->
|
||||||
|
@dialog.FileMetadataDialog(
|
||||||
|
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
|
||||||
|
"Delete File Metadata",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
|
||||||
|
"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", // Use correct classes
|
||||||
|
"Delete",
|
||||||
|
"delete",
|
||||||
|
data.File.ID,
|
||||||
|
data.File.FileName,
|
||||||
|
"details", // Indicate this is from the details view
|
||||||
|
)
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="mt-6 flex flex-wrap justify-end gap-3">
|
||||||
|
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-list mr-2"></i> Back to Files
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)} }
|
||||||
|
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||||
|
<i class="fas fa-trash mr-2"></i> Delete Record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('file-details-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataDialog renders a confirmation dialog for file metadata actions
|
||||||
|
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
|
||||||
|
@utils.FileMetadataJS()
|
||||||
|
<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>
|
||||||
|
if section == "list" {
|
||||||
|
<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("/files/%d", fileID) }
|
||||||
|
hx-target={ fmt.Sprintf("#file-row-%d", fileID) }
|
||||||
|
hx-swap="delete"
|
||||||
|
data-file-name={ fileName }
|
||||||
|
data-file-id={ fmt.Sprint(fileID) }
|
||||||
|
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
} else {
|
||||||
|
<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("/files/%d", fileID) }
|
||||||
|
hx-redirect="/files"
|
||||||
|
data-file-name={ fileName }
|
||||||
|
data-file-id={ fmt.Sprint(fileID) }
|
||||||
|
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", 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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataList renders the list of file metadata
|
||||||
|
templ FileMetadataList(ctx context.Context, data file_metadata.FileMetadataListData) {
|
||||||
|
@components.LayoutWithContext("Files", ctx) { // Call using components package
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
@utils.FileMetadataJS() // Use capitalized function name
|
||||||
|
|
||||||
|
<div id="list-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
|
Files
|
||||||
|
</h1>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-search mr-2"></i> Advanced Search
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Form -->
|
||||||
|
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Filter Files</h5>
|
||||||
|
<form
|
||||||
|
hx-get="/files/partial"
|
||||||
|
hx-target="#file-list-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#filter-loading"
|
||||||
|
hx-headers='{"X-HX-Request": "true"}'
|
||||||
|
class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
if data.Job == nil {
|
||||||
|
<div>
|
||||||
|
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job</label>
|
||||||
|
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||||
|
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||||
|
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||||
|
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||||
|
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||||
|
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||||
|
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-3 flex justify-end items-center">
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-filter mr-2"></i> Apply Filters
|
||||||
|
</button>
|
||||||
|
<div id="filter-loading" class="htmx-indicator ml-2 flex items-center">
|
||||||
|
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||||
|
File List
|
||||||
|
</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Showing { strconv.FormatInt(int64((data.Page - 1) * data.Limit + 1), 10) } to { strconv.FormatInt(int64(min(data.Page * data.Limit, int(data.TotalCount))), 10) } of { strconv.FormatInt(data.TotalCount, 10) } files
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card content -->
|
||||||
|
<div id="file-list-container" class="p-4 md:p-5">
|
||||||
|
@FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('list-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('list-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/dialog"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to generate sorting links
|
||||||
|
func sortLink(currentSortBy, currentSortDir, targetSortBy, basePath string, filter file_metadata.FileMetadataFilter, limit int) string {
|
||||||
|
nextSortDir := "asc"
|
||||||
|
if currentSortBy == targetSortBy && currentSortDir == "asc" {
|
||||||
|
nextSortDir = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("page", "1")
|
||||||
|
q.Set("limit", strconv.Itoa(limit))
|
||||||
|
q.Set("sort_by", targetSortBy)
|
||||||
|
q.Set("sort_dir", nextSortDir)
|
||||||
|
if filter.Status != "" {
|
||||||
|
q.Set("status", filter.Status)
|
||||||
|
}
|
||||||
|
if filter.FileName != "" {
|
||||||
|
q.Set("filename", filter.FileName)
|
||||||
|
}
|
||||||
|
if filter.JobID != "" {
|
||||||
|
q.Set("job_id", filter.JobID)
|
||||||
|
}
|
||||||
|
if filter.Hash != "" {
|
||||||
|
q.Set("hash", filter.Hash)
|
||||||
|
}
|
||||||
|
if filter.StartDate != "" {
|
||||||
|
q.Set("start_date", filter.StartDate)
|
||||||
|
}
|
||||||
|
if filter.EndDate != "" {
|
||||||
|
q.Set("end_date", filter.EndDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s?%s", basePath, q.Encode())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get sort icon class
|
||||||
|
func sortIconClass(currentSortBy, currentSortDir, targetSortBy string) string {
|
||||||
|
if currentSortBy == targetSortBy {
|
||||||
|
if currentSortDir == "asc" {
|
||||||
|
return "fas fa-sort-up ml-1"
|
||||||
|
}
|
||||||
|
return "fas fa-sort-down ml-1"
|
||||||
|
}
|
||||||
|
return "fas fa-sort text-gray-400 ml-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataListPartial renders the list of file metadata in a table format
|
||||||
|
// Added basePath and targetContainerID parameters
|
||||||
|
templ FileMetadataListPartial(ctx context.Context, data file_metadata.FileMetadataListData, basePath string, targetContainerID string) {
|
||||||
|
|
||||||
|
<!-- Container for dynamically generated dialogs -->
|
||||||
|
<div id="dialog-container">
|
||||||
|
for _, file := range data.Files {
|
||||||
|
@dialog.FileMetadataDialog(
|
||||||
|
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||||
|
"Delete File Metadata",
|
||||||
|
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||||
|
"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",
|
||||||
|
file.ID,
|
||||||
|
file.FileName,
|
||||||
|
"list",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File List Table -->
|
||||||
|
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||||
|
<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">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "id", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
ID <i class={ sortIconClass(data.SortBy, data.SortDir, "id") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "filename", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Filename <i class={ sortIconClass(data.SortBy, data.SortDir, "filename") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "size", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Size <i class={ sortIconClass(data.SortBy, data.SortDir, "size") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "processed_time", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Processed time <i class={ sortIconClass(data.SortBy, data.SortDir, "processed_time") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
hx-get={ sortLink(data.SortBy, data.SortDir, "status", basePath, data.Filter, data.Limit) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
Status <i class={ sortIconClass(data.SortBy, data.SortDir, "status") }></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
if data.Job == nil {
|
||||||
|
<th scope="col" class="px-6 py-3">Job</th>
|
||||||
|
}
|
||||||
|
<th scope="col" class="px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
for _, file := range data.Files {
|
||||||
|
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } 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 dark:text-white">
|
||||||
|
{ strconv.FormatUint(uint64(file.ID), 10) }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||||
|
{ file.FileName }
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{ utils.FormatFileSize(file.FileSize) }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{ file.ProcessedTime.Format("2006-01-02 15:04:05") }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(file.Status) }>
|
||||||
|
{ file.Status }
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
if data.Job == nil {
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
if file.Job.ID > 0 {
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
|
{ file.Job.Name }
|
||||||
|
</a>
|
||||||
|
} else {
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 italic">N/A</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)} }
|
||||||
|
data-file-id={ strconv.FormatUint(uint64(file.ID), 10) }
|
||||||
|
data-file-name={ file.FileName }
|
||||||
|
class="font-medium text-red-600 dark:text-red-500 hover:underline">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination with HTMX (Update links to include sorting and targetContainerID) -->
|
||||||
|
if data.TotalPages > 1 {
|
||||||
|
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between p-4" aria-label="Table navigation">
|
||||||
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0">
|
||||||
|
Showing <span class="font-semibold text-gray-900 dark:text-white">{ strconv.Itoa((data.Page-1)*data.Limit+1) }-{ strconv.Itoa(func() int {
|
||||||
|
end := data.Page*data.Limit
|
||||||
|
if int64(end) > data.TotalCount {
|
||||||
|
return int(data.TotalCount)
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}()) }</span> of <span class="font-semibold text-gray-900 dark:text-white">{ strconv.FormatInt(data.TotalCount, 10) }</span>
|
||||||
|
</span>
|
||||||
|
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||||
|
<li>
|
||||||
|
if data.Page == 1 {
|
||||||
|
<span class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
for i := 1; i <= data.TotalPages; i++ {
|
||||||
|
if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||||
|
<li>
|
||||||
|
if i == data.Page {
|
||||||
|
<span aria-current="page" class="flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
|
||||||
|
{ strconv.Itoa(i) }
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
{ strconv.Itoa(i) }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
} else if (i == 2 && data.Page > 4) || (i == data.TotalPages-1 && data.Page < data.TotalPages-3) {
|
||||||
|
<li>
|
||||||
|
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<li>
|
||||||
|
if data.Page == data.TotalPages {
|
||||||
|
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||||
|
hx-target={ targetContainerID }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||||
|
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataSearch renders the search interface for file metadata
|
||||||
|
templ FileMetadataSearch(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||||
|
@components.LayoutWithContext("Search Files", ctx) {
|
||||||
|
<!-- Status and Error Messages -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||||
|
|
||||||
|
<div id="search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
|
Search Files
|
||||||
|
</h1>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Search Form -->
|
||||||
|
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||||
|
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
|
||||||
|
<form
|
||||||
|
hx-get="/files/search/partial"
|
||||||
|
hx-target="#search-results-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#search-form-loading"
|
||||||
|
hx-headers='{"X-HX-Request": "true"}'
|
||||||
|
hx-boost="false"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
|
||||||
|
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||||
|
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||||
|
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||||
|
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||||
|
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||||
|
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||||
|
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
|
||||||
|
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
|
||||||
|
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
|
||||||
|
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2 flex justify-end">
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-search mr-2"></i> Search Files
|
||||||
|
</button>
|
||||||
|
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
|
||||||
|
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Container (Initially empty, populated by HTMX) -->
|
||||||
|
<div id="search-results-container" class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full mt-6">
|
||||||
|
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||||
|
Search Results
|
||||||
|
</h5>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Enter search criteria above and click "Search Files".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 md:p-5">
|
||||||
|
<!-- Content will be loaded here by HTMX -->
|
||||||
|
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||||
|
No results yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('search-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||||
|
"github.com/starfleetcptn/gomft/components/file_metadata/list"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadataSearchContent renders only the search results table and pagination
|
||||||
|
templ FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div id="search-results">
|
||||||
|
if len(data.Files) > 0 {
|
||||||
|
@list.FileMetadataListPartial(ctx, file_metadata.FileMetadataListData{
|
||||||
|
Files: data.Files,
|
||||||
|
Page: data.Page,
|
||||||
|
Limit: data.Limit,
|
||||||
|
TotalCount: data.TotalCount,
|
||||||
|
TotalPages: data.TotalPages,
|
||||||
|
Filter: data.Filter, // Pass filter data for pagination links
|
||||||
|
SortBy: data.SortBy,
|
||||||
|
SortDir: data.SortDir,
|
||||||
|
}, "/files/search/partial", "#search-results-container") // Pass correct base path and target ID
|
||||||
|
} else {
|
||||||
|
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="mx-auto mb-4 w-12 h-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||||
|
</svg>
|
||||||
|
<p>No files found matching your search criteria.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package file_metadata
|
||||||
|
|
||||||
|
import "github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
|
||||||
|
// FileMetadataFilter represents filter parameters for file metadata queries
|
||||||
|
type FileMetadataFilter struct {
|
||||||
|
Status string
|
||||||
|
JobID string
|
||||||
|
FileName string
|
||||||
|
Hash string
|
||||||
|
StartDate string
|
||||||
|
EndDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataListData contains data for the file metadata list template
|
||||||
|
type FileMetadataListData struct {
|
||||||
|
Files []db.FileMetadata
|
||||||
|
TotalCount int64
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
TotalPages int
|
||||||
|
Job *db.Job // Optional: if viewing files for a specific job
|
||||||
|
Filter FileMetadataFilter
|
||||||
|
SortBy string // Added for sorting
|
||||||
|
SortDir string // Added for sorting ("asc" or "desc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataDetailsData contains data for the file metadata details template
|
||||||
|
type FileMetadataDetailsData struct {
|
||||||
|
File db.FileMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMetadataSearchData contains data for the file metadata search template
|
||||||
|
type FileMetadataSearchData struct {
|
||||||
|
Files []db.FileMetadata
|
||||||
|
TotalCount int64
|
||||||
|
Page int
|
||||||
|
Limit int
|
||||||
|
TotalPages int
|
||||||
|
Filter FileMetadataFilter
|
||||||
|
SortBy string // Added for sorting
|
||||||
|
SortDir string // Added for sorting ("asc" or "desc")
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
// FileMetadataJS provides common JavaScript functions for file metadata components,
|
||||||
|
// including HTMX event listeners for delete toasts.
|
||||||
|
templ FileMetadataJS() {
|
||||||
|
<script>
|
||||||
|
// Function to create and show a toast (Defined locally for guaranteed availability)
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
if (!toastContainer) {
|
||||||
|
console.error("Toast container not found!"); // Keep this error log
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
|
// Use classes similar to the original file_metadata.templ for consistency
|
||||||
|
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;
|
||||||
|
if (type === 'success') {
|
||||||
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
|
} else { // Default to info
|
||||||
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 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
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
toast.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTMX Event Listener for Delete Toasts ---
|
||||||
|
|
||||||
|
// Ensure listener is attached only once using a flag
|
||||||
|
if (!window._gomft_fileMetadataListenerAttached) {
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
const triggerElement = event.detail.elt;
|
||||||
|
|
||||||
|
// Check if the element that triggered this request was the file delete button from the dialog
|
||||||
|
if (triggerElement && triggerElement.id && triggerElement.id.startsWith('delete-file-btn-')) {
|
||||||
|
// Get the path directly from the element's hx-delete attribute
|
||||||
|
const path = triggerElement.getAttribute('hx-delete');
|
||||||
|
// Ensure requestConfig exists before accessing verb (robustness)
|
||||||
|
const method = event.detail.requestConfig ? event.detail.requestConfig.verb : null;
|
||||||
|
|
||||||
|
// Check if the method was delete and the path from the attribute matches the expected pattern
|
||||||
|
if (method === 'delete' && path && path.match(/^\/files\/\d+$/)) {
|
||||||
|
const fileName = triggerElement.getAttribute('data-file-name') || "Unknown"; // Get filename from the button
|
||||||
|
|
||||||
|
// Call the locally defined showToast function
|
||||||
|
if (event.detail.successful) {
|
||||||
|
showToast(`File "${fileName}" metadata deleted successfully`, 'success');
|
||||||
|
} else {
|
||||||
|
let errorMsg = `Failed to delete file "${fileName}" metadata`;
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
try {
|
||||||
|
const responseJson = JSON.parse(event.detail.xhr.responseText);
|
||||||
|
errorMsg = responseJson.error ? `Error: ${responseJson.error}` : `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the flag to true after attaching the listener
|
||||||
|
window._gomft_fileMetadataListenerAttached = true;
|
||||||
|
console.log("[FileMetadataJS] HTMX afterRequest listener attached."); // Log attachment once
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// GetStatusBadgeClass returns the appropriate CSS class for a file status badge
|
||||||
|
func GetStatusBadgeClass(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "processed":
|
||||||
|
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||||
|
case "archived":
|
||||||
|
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
|
||||||
|
case "deleted":
|
||||||
|
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
|
||||||
|
case "archived_and_deleted":
|
||||||
|
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"
|
||||||
|
case "error":
|
||||||
|
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFileSize formats a file size in bytes to a human-readable string
|
||||||
|
func FormatFileSize(size int64) string {
|
||||||
|
if size < 1024 {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
} else if size < 1024*1024 {
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(size)/1024)
|
||||||
|
} else if size < 1024*1024*1024 {
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
-203
@@ -6,102 +6,91 @@ import (
|
|||||||
|
|
||||||
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
|
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
|
||||||
@LayoutWithContext("Forgot Password", ctx) {
|
@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="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">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||||
<div class="p-8">
|
<div class="text-center mb-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">
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Password Reset</h2>
|
||||||
if errorMessage != "" {
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Enter your email to receive a reset link</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
if errorMessage != "" {
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
<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">
|
||||||
<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-exclamation-circle mr-2"></i>
|
||||||
<i class="fas fa-arrow-left mr-2"></i>
|
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||||
Back to login
|
</div>
|
||||||
</a>
|
}
|
||||||
</p>
|
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<div class="text-center">
|
||||||
<div class="mt-8 text-center">
|
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<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>
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
<span>Secure, encrypted connection</span>
|
<span>Secure, encrypted connection</span>
|
||||||
</div>
|
</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
|
// Reset password page for when users click the link from their email
|
||||||
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
|
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
|
||||||
@LayoutWithContext("Reset Password", ctx) {
|
@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="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">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||||
<div class="p-8">
|
<div class="text-center mb-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">
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
|
||||||
if errorMessage != "" {
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Create a new password for your account</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
if errorMessage != "" {
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
<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">
|
||||||
<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-exclamation-circle mr-2"></i>
|
||||||
<i class="fas fa-arrow-left mr-2"></i>
|
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||||
Back to login
|
</div>
|
||||||
</a>
|
}
|
||||||
</p>
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<div class="text-center">
|
||||||
<div class="mt-8 text-center">
|
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<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>
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
<span>Secure, encrypted connection</span>
|
<span>Secure, encrypted connection</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+150
-96
@@ -49,66 +49,88 @@ func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.Transfer
|
|||||||
// HistoryContent renders only the content part of the history page for HTMX requests
|
// HistoryContent renders only the content part of the history page for HTMX requests
|
||||||
templ HistoryContent(ctx context.Context, data HistoryData) {
|
templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||||
if len(data.History) == 0 {
|
if len(data.History) == 0 {
|
||||||
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
|
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
<div class="flex justify-center mb-4">
|
||||||
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<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>
|
</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 != "" {
|
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 {
|
} 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 {
|
} 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>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
|
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
for _, history := range data.History {
|
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="px-4 py-4 sm:px-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
||||||
if history.Status == "completed" {
|
if history.Status == "completed" {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||||
Completed
|
Completed
|
||||||
</span>
|
</span>
|
||||||
} else if history.Status == "failed" {
|
} else if history.Status == "failed" {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||||
Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} else {
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||||
{ history.Status }
|
{ history.Status }
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
|
<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">
|
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"></i>
|
<i class="fas fa-eye mr-1.5"></i>
|
||||||
View Details
|
View Details
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 sm:flex sm:justify-between">
|
<div class="mt-2 sm:flex sm:justify-between">
|
||||||
<div class="sm:flex">
|
<div class="sm:flex">
|
||||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<p class="flex items-center text-sm text-gray-500 dark:text-gray-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>
|
<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") }
|
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
</p>
|
</p>
|
||||||
if history.EndTime != nil {
|
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">
|
<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 flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
<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() }
|
Duration: { history.EndTime.Sub(history.StartTime).String() }
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</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 {
|
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>
|
<p>
|
||||||
{ formatBytes(history.BytesTransferred) } transferred
|
{ formatBytes(history.BytesTransferred) } transferred
|
||||||
</p>
|
</p>
|
||||||
@@ -116,9 +138,9 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if history.ErrorMessage != "" {
|
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">
|
<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 }
|
Error: { history.ErrorMessage }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,15 +153,17 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
if data.TotalPages > 1 {
|
if data.TotalPages > 1 {
|
||||||
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
<div class="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">
|
||||||
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full px-4 mb-4 sm:mb-0">
|
<!-- Mobile pagination -->
|
||||||
|
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-prev-indicator"
|
hx-indicator="#mobile-prev-indicator"
|
||||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||||
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
</span>
|
</span>
|
||||||
@@ -148,7 +172,7 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -159,7 +183,8 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#mobile-next-indicator"
|
hx-indicator="#mobile-next-indicator"
|
||||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||||
|
>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -168,14 +193,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between px-4">
|
|
||||||
|
<!-- Desktop pagination -->
|
||||||
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Showing
|
Showing
|
||||||
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
|
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
|
||||||
to
|
to
|
||||||
@@ -185,23 +212,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
results
|
results
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center">
|
<div>
|
||||||
<nav class="flex gap-2" aria-label="Pagination">
|
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||||
if data.CurrentPage > 1 {
|
if data.CurrentPage > 1 {
|
||||||
<button
|
<button
|
||||||
hx-get="/history"
|
hx-get="/history"
|
||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#prev-indicator"
|
hx-indicator="#prev-indicator"
|
||||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
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"></i>
|
>
|
||||||
|
<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">
|
<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>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<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"></i>
|
<i class="fas fa-chevron-left text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,15 +242,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||||
hx-indicator="#next-indicator"
|
hx-indicator="#next-indicator"
|
||||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
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"></i>
|
>
|
||||||
|
<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">
|
<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>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else {
|
} else {
|
||||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
<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"></i>
|
<i class="fas fa-chevron-right text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -235,42 +264,45 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
|||||||
|
|
||||||
templ History(ctx context.Context, data HistoryData) {
|
templ History(ctx context.Context, data HistoryData) {
|
||||||
@LayoutWithContext("Transfer History", ctx) {
|
@LayoutWithContext("Transfer History", ctx) {
|
||||||
<div id="history-page" class="py-6">
|
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<!-- Page Header -->
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<i class="fas fa-history 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-history w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
Transfer History
|
Transfer History
|
||||||
</h1>
|
</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>
|
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search and Pagination Controls -->
|
<!-- 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">
|
<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="w-full sm:w-auto mb-4 sm:mb-0">
|
<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="flex">
|
<div class="w-full sm:w-auto relative flex-grow">
|
||||||
<div class="relative flex-grow">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<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>
|
||||||
<i class="fas fa-search text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="search"
|
|
||||||
value={ data.SearchTerm }
|
|
||||||
placeholder="Search by job name or status..."
|
|
||||||
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="page" value="1" />
|
|
||||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<button
|
||||||
type="submit"
|
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>
|
<i class="fas fa-search mr-2 -ml-1"></i>
|
||||||
<span id="search-indicator" class="htmx-indicator ml-1">
|
Search
|
||||||
|
<span id="search-indicator" class="htmx-indicator ml-2">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -280,46 +312,65 @@ templ History(ctx context.Context, data HistoryData) {
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-indicator="#clear-indicator"
|
hx-indicator="#clear-indicator"
|
||||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
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
|
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>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
|
||||||
<div id="history-content" class="mt-6">
|
<div id="history-content" class="mt-6">
|
||||||
@HistoryContent(ctx, data)
|
@HistoryContent(ctx, data)
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +379,7 @@ templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm stri
|
|||||||
// Show at most 5 page numbers with the current page in the middle when possible
|
// Show at most 5 page numbers with the current page in the middle when possible
|
||||||
for i := 1; i <= totalPages; i++ {
|
for i := 1; i <= totalPages; i++ {
|
||||||
if i == currentPage {
|
if i == currentPage {
|
||||||
<span aria-current="page" class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600">
|
||||||
{ fmt.Sprint(i) }
|
{ fmt.Sprint(i) }
|
||||||
</span>
|
</span>
|
||||||
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||||
@@ -337,14 +388,17 @@ templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm stri
|
|||||||
hx-target="#history-content"
|
hx-target="#history-content"
|
||||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||||
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||||
|
>
|
||||||
{ fmt.Sprint(i) }
|
{ 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">
|
<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>
|
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
} else if i == currentPage-3 || i == currentPage+3 {
|
} else if i == currentPage-3 || i == currentPage+3 {
|
||||||
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-98
@@ -6,130 +6,155 @@ import (
|
|||||||
|
|
||||||
templ Home(ctx context.Context) {
|
templ Home(ctx context.Context) {
|
||||||
@LayoutWithContext("Home", ctx) {
|
@LayoutWithContext("Home", ctx) {
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="text-center">
|
<!-- Page Header -->
|
||||||
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
|
<div class="mb-8 text-center">
|
||||||
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
|
<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
|
Welcome to GoMFT
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
A modern managed file transfer solution
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<div class="space-y-8">
|
<!-- Logged-in User View -->
|
||||||
<!-- Feature Cards -->
|
<div class="space-y-8">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<!-- Feature Cards -->
|
||||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div class="p-6">
|
<!-- Transfer Configs Card -->
|
||||||
<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">
|
<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">
|
||||||
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
|
<div class="p-6 text-center">
|
||||||
</div>
|
<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">
|
||||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
|
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Access Section -->
|
<!-- Schedule Jobs Card -->
|
||||||
<div class="mt-12">
|
<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">
|
||||||
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
|
<div class="p-6 text-center">
|
||||||
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
|
<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">
|
||||||
Quick Access
|
<i class="fas fa-calendar-alt text-indigo-600 dark:text-indigo-300 text-2xl"></i>
|
||||||
</h2>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Schedule Jobs</h2>
|
||||||
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
<p class="text-gray-500 dark:text-gray-400 mb-5">Create and monitor automated file transfer jobs.</p>
|
||||||
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
<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">
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
|
View Jobs
|
||||||
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
</div>
|
||||||
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
</div>
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
|
|
||||||
</a>
|
<!-- Track History Card -->
|
||||||
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
<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">
|
||||||
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
<div class="p-6 text-center">
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
|
<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">
|
||||||
</a>
|
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
||||||
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
</div>
|
||||||
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Track History</h2>
|
||||||
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
|
||||||
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
|
<!-- Quick Access Section -->
|
||||||
<div class="inline-flex items-center justify-center w-64 h-64 mb-4">
|
<div class="mt-10">
|
||||||
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
|
<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>
|
</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 -->
|
<!-- Features Preview -->
|
||||||
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
|
<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-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
|
<h3 class="text-base font-medium text-gray-900 dark:text-white mb-4">Key Features</h3>
|
||||||
<ul class="space-y-3 text-left">
|
<ul class="space-y-3">
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
|
<span class="text-gray-500 dark:text-gray-400">Secure file transfers with encryption</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
|
<span class="text-gray-500 dark:text-gray-400">Automated scheduling and monitoring</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
|
<span class="text-gray-500 dark:text-gray-400">Comprehensive transfer history tracking</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||||
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
|
<span class="text-gray-500 dark:text-gray-400">User-friendly interface with dark mode support</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+368
-547
@@ -245,560 +245,381 @@ templ configSearchScript() {
|
|||||||
templ JobForm(ctx context.Context, data JobFormData) {
|
templ JobForm(ctx context.Context, data JobFormData) {
|
||||||
@LayoutWithContext(getJobFormTitle(data.IsNew), ctx) {
|
@LayoutWithContext(getJobFormTitle(data.IsNew), ctx) {
|
||||||
@configSearchScript()
|
@configSearchScript()
|
||||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
|
||||||
<div class="max-w-3xl w-full">
|
<!-- Main Content -->
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<section class="py-8 px-4">
|
||||||
<div class="p-8">
|
<div class="mx-auto max-w-3xl">
|
||||||
<div class="text-center mb-8">
|
<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">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
<!-- Form Header -->
|
||||||
<i class="fas fa-calendar-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
|
<div class="mb-8 text-center">
|
||||||
</div>
|
<div class="flex justify-center mb-4">
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
|
||||||
{ getJobTitle(data.IsNew) }
|
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||||
</h2>
|
</span>
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Configure your scheduled transfer job</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
if data.IsNew {
|
{ getJobTitle(data.IsNew) }
|
||||||
<form
|
</h1>
|
||||||
id="new-job-form"
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
class="space-y-6"
|
Configure your scheduled file transfer job using the form below
|
||||||
hx-post="/jobs"
|
|
||||||
hx-target="body"
|
|
||||||
hx-boost="true">
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
id="name"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Daily Production Backup"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Descriptive name for this job (optional). If not provided, the config name will be used.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configurations</label>
|
|
||||||
<div class="mt-2 border border-secondary-300 dark:border-secondary-700 rounded-md overflow-hidden">
|
|
||||||
<!-- Search box -->
|
|
||||||
<div class="px-3 py-2 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-800">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-search text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="config-search"
|
|
||||||
placeholder="Search configurations..."
|
|
||||||
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Configuration checkboxes -->
|
|
||||||
<div class="max-h-48 overflow-y-auto py-2 px-3 bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700" id="config-list">
|
|
||||||
if len(data.Configs) > 0 {
|
|
||||||
for _, config := range data.Configs {
|
|
||||||
<div class="config-item py-2 flex items-center" data-name={ config.Name }>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="config_ids[]"
|
|
||||||
id={ fmt.Sprintf("config_%d", config.ID) }
|
|
||||||
value={ fmt.Sprint(config.ID) }
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
|
|
||||||
/>
|
|
||||||
<label for={ fmt.Sprintf("config_%d", config.ID) } class="ml-3 block font-medium text-secondary-700 dark:text-secondary-300 w-full cursor-pointer">
|
|
||||||
{ config.Name }
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">
|
|
||||||
No configurations available. <a href="/configs/new" class="text-primary-600 hover:text-primary-500">Create one</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected Configurations Order List -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
|
||||||
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
|
|
||||||
</label>
|
|
||||||
<div id="selected-configs" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
|
|
||||||
<!-- Selected items will be populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Use the arrows to change the order in which configurations will execute.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="schedule"
|
|
||||||
id="schedule"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="*/15 * * * *"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="enabled"
|
|
||||||
name="enabled"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetEnabled() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
|
|
||||||
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
Jobs that are not enabled will not run automatically on schedule.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Webhook Notification Settings -->
|
|
||||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-6 mt-6">
|
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">
|
|
||||||
<i class="fas fa-bell mr-2 text-primary-500"></i>
|
|
||||||
Webhook Notifications
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="webhook_enabled"
|
|
||||||
name="webhook_enabled"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetWebhookEnabled() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
|
|
||||||
<label for="webhook_enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
|
||||||
Send webhook notification on completion
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pl-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="webhook_url" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Webhook URL</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-link text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
name="webhook_url"
|
|
||||||
id="webhook_url"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://example.com/webhook"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
The URL where notifications will be sent when jobs run
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="webhook_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
||||||
Webhook Secret <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="webhook_secret"
|
|
||||||
id="webhook_secret"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Secret token for signing requests"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Used to sign webhook payloads (X-Hub-Signature-256 header)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="webhook_headers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
||||||
Custom Headers <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-code text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="webhook_headers"
|
|
||||||
id="webhook_headers"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder='{"X-Custom-Header": "value"}'/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Additional HTTP headers as JSON
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="notify_on_success"
|
|
||||||
name="notify_on_success"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetNotifyOnSuccess() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
|
||||||
<label for="notify_on_success" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
|
||||||
Notify on successful jobs
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="notify_on_failure"
|
|
||||||
name="notify_on_failure"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetNotifyOnFailure() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
|
||||||
<label for="notify_on_failure" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
|
||||||
Notify on failed jobs
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-5 flex justify-end space-x-3">
|
|
||||||
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
|
|
||||||
<i class="fas fa-times mr-2"></i>
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary flex items-center justify-center px-4 py-2">
|
|
||||||
<i class="fas fa-plus mr-2"></i>
|
|
||||||
Create Job
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
} else {
|
|
||||||
<form
|
|
||||||
id="edit-job-form"
|
|
||||||
class="space-y-6"
|
|
||||||
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
|
|
||||||
hx-target="body"
|
|
||||||
hx-boost="true"
|
|
||||||
data-config-order={ data.Job.ConfigIDs }>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
id="name"
|
|
||||||
value={ data.Job.Name }
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Daily Production Backup"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Descriptive name for this job (optional). If not provided, the config name will be used.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configurations</label>
|
|
||||||
<div class="mt-2 border border-secondary-300 dark:border-secondary-700 rounded-md overflow-hidden">
|
|
||||||
<!-- Search box -->
|
|
||||||
<div class="px-3 py-2 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-800">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-search text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="config-search-edit"
|
|
||||||
placeholder="Search configurations..."
|
|
||||||
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Configuration checkboxes -->
|
|
||||||
<div class="max-h-48 overflow-y-auto py-2 px-3 bg-white dark:bg-secondary-900 divide-y divide-secondary-200 dark:divide-secondary-700" id="config-list-edit">
|
|
||||||
if len(data.Configs) > 0 {
|
|
||||||
for _, config := range data.Configs {
|
|
||||||
<div class="config-item py-2 flex items-center" data-name={ config.Name }>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="config_ids[]"
|
|
||||||
id={ fmt.Sprintf("config_edit_%d", config.ID) }
|
|
||||||
value={ fmt.Sprint(config.ID) }
|
|
||||||
if configSelected(data.Job, config.ID) {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
|
|
||||||
/>
|
|
||||||
<label for={ fmt.Sprintf("config_edit_%d", config.ID) } class="ml-3 block font-medium text-secondary-700 dark:text-secondary-300 w-full cursor-pointer">
|
|
||||||
{ config.Name }
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">
|
|
||||||
No configurations available. <a href="/configs/new" class="text-primary-600 hover:text-primary-500">Create one</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected Configurations Order List for edit -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
|
||||||
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
|
|
||||||
</label>
|
|
||||||
<div id="selected-configs-edit" class="border border-secondary-300 dark:border-secondary-700 rounded-md p-3 min-h-20 bg-secondary-50 dark:bg-secondary-900">
|
|
||||||
<!-- Selected items will be populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Use the arrows to change the order in which configurations will execute.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="schedule"
|
|
||||||
id="schedule"
|
|
||||||
value={ data.Job.Schedule }
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="*/15 * * * *"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="enabled"
|
|
||||||
name="enabled"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetEnabled() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
|
|
||||||
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
Jobs that are not enabled will not run automatically on schedule.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Webhook Notification Settings -->
|
|
||||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-6 mt-6">
|
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">
|
|
||||||
<i class="fas fa-bell mr-2 text-primary-500"></i>
|
|
||||||
Webhook Notifications
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="webhook_enabled"
|
|
||||||
name="webhook_enabled"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetWebhookEnabled() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
|
|
||||||
<label for="webhook_enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
|
||||||
Send webhook notification on completion
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pl-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="webhook_url" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Webhook URL</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-link text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
name="webhook_url"
|
|
||||||
id="webhook_url"
|
|
||||||
value={ data.Job.WebhookURL }
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://example.com/webhook"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
The URL where notifications will be sent when jobs run
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="webhook_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
||||||
Webhook Secret <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="webhook_secret"
|
|
||||||
id="webhook_secret"
|
|
||||||
value={ data.Job.WebhookSecret }
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Secret token for signing requests"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Used to sign webhook payloads (X-Hub-Signature-256 header)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="webhook_headers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
||||||
Custom Headers <span class="text-secondary-500 dark:text-secondary-400">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-code text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="webhook_headers"
|
|
||||||
id="webhook_headers"
|
|
||||||
value={ data.Job.WebhookHeaders }
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder='{"X-Custom-Header": "value"}'/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Additional HTTP headers as JSON
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="notify_on_success"
|
|
||||||
name="notify_on_success"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetNotifyOnSuccess() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
|
||||||
<label for="notify_on_success" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
|
||||||
Notify on successful jobs
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="notify_on_failure"
|
|
||||||
name="notify_on_failure"
|
|
||||||
value="true"
|
|
||||||
if data.Job.GetNotifyOnFailure() {
|
|
||||||
checked
|
|
||||||
}
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
|
||||||
<label for="notify_on_failure" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">
|
|
||||||
Notify on failed jobs
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-5 flex justify-end space-x-3">
|
|
||||||
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
|
|
||||||
<i class="fas fa-times mr-2"></i>
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary flex items-center justify-center px-4 py-2">
|
|
||||||
<i class="fas fa-save mr-2"></i>
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Jobs will run according to their schedule and execute the selected transfer configurations in the order specified
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Help Notice -->
|
<!-- Help Card -->
|
||||||
<div class="mt-8 text-center">
|
<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="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="flex">
|
||||||
<i class="fas fa-question-circle mr-2 text-primary-500"></i>
|
<div class="flex-shrink-0">
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+150
-156
@@ -22,169 +22,163 @@ templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
|||||||
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
|
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
|
||||||
// This is used for testing
|
// This is used for testing
|
||||||
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
|
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
|
||||||
<div class="py-6">
|
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="mb-6">
|
||||||
<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">
|
||||||
<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-2"></i> Back to Dashboard
|
||||||
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
<i class="fas fa-file-alt mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||||
Job Run Details
|
Job Run Details
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Job Run Information Card -->
|
<!-- Job Run Information Card -->
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
<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-secondary-200 dark:border-secondary-700">
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">{ data.Job.Name }</h3>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{ data.Job.Name }</h3>
|
||||||
if data.JobHistory.Status == "completed" {
|
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">
|
<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-1"></i> Completed
|
<i class="fas fa-check mr-2"></i> Completed
|
||||||
</span>
|
</span>
|
||||||
} else if data.JobHistory.Status == "failed" {
|
} 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">
|
<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-1"></i> Failed
|
<i class="fas fa-times mr-2"></i> Failed
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} 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">
|
<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-1"></i> Running
|
<i class="fas fa-spinner fa-spin mr-2"></i> Running
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Config: { data.Config.Name }</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="sm:col-span-1">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
|
<i class="fas fa-calendar-alt mr-2 text-gray-400 dark:text-gray-500"></i> Start Time
|
||||||
|
</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Config: { data.Config.Name }</p>
|
<div class="sm:col-span-1">
|
||||||
</div>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<i class="fas fa-calendar-check mr-2 text-gray-400 dark:text-gray-500"></i> End Time
|
||||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
</dt>
|
||||||
<div class="sm:col-span-1">
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
if data.JobHistory.EndTime != nil {
|
||||||
<i class="fas fa-calendar-alt mr-1"></i> Start Time
|
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||||
</dt>
|
} else {
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||||
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-calendar-check mr-1"></i> End Time
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
if data.JobHistory.EndTime != nil {
|
|
||||||
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
|
||||||
} else {
|
|
||||||
<span class="italic text-secondary-500">In progress</span>
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-clock mr-1"></i> Duration
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
if data.JobHistory.EndTime != nil {
|
|
||||||
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
|
||||||
} else {
|
|
||||||
<span class="italic text-secondary-500">In progress</span>
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-upload mr-1"></i> Data Transferred
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ formatBytes(data.JobHistory.BytesTransferred) }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-file mr-1"></i> Files Transferred
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
{ data.Job.Schedule }
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transfer Configuration Details -->
|
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
|
||||||
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
|
|
||||||
Transfer Configuration
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.SourceType }</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
|
||||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.DestinationType }</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.SourcePath }</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.DestinationPath }</dd>
|
|
||||||
</div>
|
|
||||||
<div class="sm:col-span-1">
|
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
|
|
||||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">{ data.Config.FilePattern }</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Information (if any) -->
|
|
||||||
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
|
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
|
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
|
||||||
Error Information
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="sm:col-span-1">
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
<pre class="text-sm text-red-800 dark:text-red-300 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
|
<i class="fas fa-clock mr-2 text-gray-400 dark:text-gray-500"></i> Duration
|
||||||
</div>
|
</dt>
|
||||||
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
|
if data.JobHistory.EndTime != nil {
|
||||||
|
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
||||||
|
} else {
|
||||||
|
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
<!-- Action Buttons -->
|
</dt>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
<dd class="text-sm text-gray-900 dark:text-white">
|
||||||
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
|
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||||
<i class="fas fa-list-ul mr-2"></i> View All Jobs
|
</dd>
|
||||||
</a>
|
</div>
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
|
<div class="sm:col-span-1">
|
||||||
<i class="fas fa-edit mr-2"></i> Edit Job
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||||
</a>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+273
-187
@@ -6,55 +6,46 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"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) {
|
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 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">
|
||||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
<!-- Backdrop -->
|
||||||
<div class="px-6 pt-5 pb-3 text-center">
|
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||||
<div class="flex justify-center mb-2">
|
<!-- Modal content -->
|
||||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
<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>
|
</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>
|
</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) {
|
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
|
||||||
// Hide the dialog
|
// Hide the dialog
|
||||||
document.getElementById(dialogId).classList.add("hidden");
|
document.getElementById(dialogId).classList.add("hidden");
|
||||||
|
document.getElementById(dialogId).classList.remove("flex");
|
||||||
|
|
||||||
// Add debugging info
|
// Add debugging info
|
||||||
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
|
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
|
||||||
@@ -75,38 +66,84 @@ type JobsData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ Jobs(ctx context.Context, data JobsData) {
|
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>
|
<script>
|
||||||
// Debug notification system
|
// Debug notification system
|
||||||
console.log("Jobs template loaded, setting up notification system");
|
console.log("Jobs template loaded, setting up notification system");
|
||||||
|
|
||||||
// Create a global notyf instance immediately
|
// Function to create and show a toast
|
||||||
window.notyf = new Notyf({
|
function showToast(message, type) {
|
||||||
duration: 3000,
|
const toastContainer = document.getElementById('toast-container');
|
||||||
position: {
|
|
||||||
x: 'right',
|
// Create toast element
|
||||||
y: 'top',
|
const toast = document.createElement('div');
|
||||||
},
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
types: [
|
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';
|
||||||
type: 'success',
|
|
||||||
background: '#38c172',
|
// Set toast content based on type
|
||||||
icon: {
|
let iconClass, bgColorClass, textColorClass;
|
||||||
className: 'fas fa-check-circle',
|
|
||||||
tagName: 'i'
|
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';
|
||||||
type: 'error',
|
} else if (type === 'error') {
|
||||||
background: '#e3342f',
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
icon: {
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
className: 'fas fa-exclamation-circle',
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
tagName: 'i'
|
} 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';
|
||||||
});
|
}
|
||||||
console.log("Notyf initialized:", window.notyf);
|
|
||||||
|
// 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
|
// Global function to handle job running
|
||||||
window.runJob = function(button) {
|
window.runJob = function(button) {
|
||||||
@@ -126,22 +163,33 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
displayName = event.detail.headers['HX-Job-Name'];
|
displayName = event.detail.headers['HX-Job-Name'];
|
||||||
}
|
}
|
||||||
console.log(`Showing success notification for job: ${displayName}`);
|
console.log(`Showing success notification for job: ${displayName}`);
|
||||||
window.notyf.success(`Job "${displayName}" started successfully!`);
|
showToast(`Job "${displayName}" started successfully!`, 'success');
|
||||||
} else {
|
} else {
|
||||||
let errorMsg = `Failed to start job "${jobName}"`;
|
let errorMsg = `Failed to start job "${jobName}"`;
|
||||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||||
}
|
}
|
||||||
console.log(`Showing error notification: ${errorMsg}`);
|
console.log(`Showing error notification: ${errorMsg}`);
|
||||||
window.notyf.error(errorMsg);
|
showToast(errorMsg, 'error');
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { 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
|
// Track all HTMX events for debugging
|
||||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||||
console.log("HTMX before request:", event.detail);
|
|
||||||
|
|
||||||
// Check if this is a DELETE request by examining the URL and method
|
// Check if this is a DELETE request by examining the URL and method
|
||||||
const path = event.detail.path;
|
const path = event.detail.path;
|
||||||
const method = event.detail.verb;
|
const method = event.detail.verb;
|
||||||
@@ -159,8 +207,6 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
|
|
||||||
// Track HTMX after-request events for job deletion
|
// Track HTMX after-request events for job deletion
|
||||||
document.addEventListener('htmx:afterRequest', function(event) {
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
console.log("HTMX after request:", event.detail);
|
|
||||||
|
|
||||||
// Check for job deletion multiple ways
|
// Check for job deletion multiple ways
|
||||||
const isDeleteRequest =
|
const isDeleteRequest =
|
||||||
// Check global flag from the triggerJobDelete function
|
// Check global flag from the triggerJobDelete function
|
||||||
@@ -191,7 +237,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Showing success notification for deleted job: ${jobName}`);
|
console.log(`Showing success notification for deleted job: ${jobName}`);
|
||||||
window.notyf.success(`Job "${jobName}" deleted successfully`);
|
showToast(`Job "${jobName}" deleted successfully`, 'success');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingJob = false;
|
window.currentlyDeletingJob = false;
|
||||||
@@ -234,7 +280,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Showing error notification: ${errorMsg}`);
|
console.log(`Showing error notification: ${errorMsg}`);
|
||||||
window.notyf.error(errorMsg);
|
showToast(errorMsg, 'error');
|
||||||
|
|
||||||
// Clear flags
|
// Clear flags
|
||||||
window.currentlyDeletingJob = false;
|
window.currentlyDeletingJob = false;
|
||||||
@@ -244,147 +290,187 @@ templ Jobs(ctx context.Context, data JobsData) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="py-6">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="pb-8 w-full">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<i class="fas fa-exchange-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
<i class="fas fa-calendar-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
Transfer Jobs
|
Scheduled Jobs
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/jobs/new" class="btn-primary">
|
<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 mr-2"></i>
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
New Job
|
New Job
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
if len(data.Jobs) == 0 {
|
if len(data.Jobs) == 0 {
|
||||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
<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-secondary-100 dark:bg-secondary-700 mb-4">
|
<div class="inline-block p-4 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
|
||||||
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<i class="fas fa-list text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No jobs</h3>
|
<h3 class="mt-2 text-lg font-medium text-gray-900 dark:text-white">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>
|
<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">
|
<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>
|
<i class="fas fa-plus mr-2"></i>
|
||||||
New Job
|
New Job
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="card overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg overflow-hidden">
|
||||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
<div class="relative overflow-x-auto">
|
||||||
for _, job := range data.Jobs {
|
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<li>
|
for _, job := range data.Jobs {
|
||||||
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
<li>
|
||||||
<div class="px-4 py-4 sm:px-6">
|
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
<div class="flex items-center justify-between">
|
<div class="px-4 py-4 sm:px-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
<div class="flex items-center">
|
||||||
if job.Name != "" {
|
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||||
{ job.Name }
|
if job.Name != "" {
|
||||||
} else {
|
{ job.Name }
|
||||||
{ job.Config.Name }
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
if job.GetEnabled() {
|
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
} else {
|
|
||||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
|
||||||
<button
|
|
||||||
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
|
|
||||||
hx-swap="none"
|
|
||||||
class="btn-primary btn-sm"
|
|
||||||
data-job-id={ fmt.Sprint(job.ID) }
|
|
||||||
data-job-name={ job.Name }
|
|
||||||
onclick="window.runJob(this)">
|
|
||||||
<i class="fas fa-play mr-1"></i>
|
|
||||||
Run Now
|
|
||||||
</button>
|
|
||||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
|
|
||||||
<i class="fas fa-edit mr-1"></i>
|
|
||||||
Edit
|
|
||||||
</a>
|
|
||||||
<!-- Add delete dialog for each job -->
|
|
||||||
@JobDialog(
|
|
||||||
fmt.Sprintf("delete-job-dialog-%d", job.ID),
|
|
||||||
"Delete Job",
|
|
||||||
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
|
|
||||||
"btn-danger",
|
|
||||||
"Delete",
|
|
||||||
"delete",
|
|
||||||
job.ID,
|
|
||||||
determineJobName(job),
|
|
||||||
)
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
|
||||||
class="btn-danger btn-sm">
|
|
||||||
<i class="fas fa-trash-alt mr-1"></i>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 sm:flex sm:justify-between">
|
|
||||||
<div class="sm:flex">
|
|
||||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fas fa-cogs flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
Configs:
|
|
||||||
<span class="ml-1">
|
|
||||||
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
|
|
||||||
{ fmt.Sprintf("%d configurations", count) }
|
|
||||||
} else if job.ConfigID > 0 {
|
|
||||||
{ job.Config.Name }
|
|
||||||
} else {
|
} else {
|
||||||
{ "None" }
|
{ job.Config.Name }
|
||||||
}
|
}
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
|
||||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
Schedule: { job.Schedule }
|
|
||||||
</p>
|
|
||||||
if job.LastRun != nil {
|
|
||||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
|
||||||
<i class="fas fa-history flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
|
|
||||||
</p>
|
</p>
|
||||||
|
if job.GetEnabled() {
|
||||||
|
<span class="inline-flex items-center bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 ml-2">
|
||||||
|
<i class="fas fa-circle-check text-green-500 dark:text-green-400 mr-1"></i>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="inline-flex items-center bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300 ml-2">
|
||||||
|
<i class="fas fa-circle-xmark text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||||
|
<button
|
||||||
|
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
|
||||||
|
hx-swap="none"
|
||||||
|
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
|
data-job-id={ fmt.Sprint(job.ID) }
|
||||||
|
data-job-name={ job.Name }
|
||||||
|
onclick="window.runJob(this)">
|
||||||
|
<i class="fas fa-play mr-1"></i>
|
||||||
|
Run Now
|
||||||
|
</button>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="py-1.5 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||||
|
<i class="fas fa-pen-to-square mr-1"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
if job.NextRun != nil {
|
|
||||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
|
||||||
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
|
||||||
<p>
|
|
||||||
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
}
|
||||||
}
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help Notice -->
|
<!-- Help Notice -->
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="flex">
|
||||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
<div class="flex-shrink-0">
|
||||||
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||||
</p>
|
</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>
|
</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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+412
-406
@@ -2,15 +2,17 @@ package components
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"time"
|
"time"
|
||||||
"fmt"
|
"github.com/starfleetcptn/gomft/components/shared/toast"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppVersion will be set at build time using ldflags
|
// AppVersion will be set at build time using ldflags
|
||||||
// Example build command:
|
// Example build command:
|
||||||
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
|
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
|
||||||
var AppVersion = "dev"
|
var AppVersion = "DEV"
|
||||||
|
|
||||||
// GetReleaseURL returns the URL to the specific GitHub release
|
// GetReleaseURL returns the URL to the specific GitHub release
|
||||||
func GetReleaseURL() templ.SafeURL {
|
func GetReleaseURL() templ.SafeURL {
|
||||||
@@ -41,465 +43,469 @@ templ Layout(title string) {
|
|||||||
|
|
||||||
templ LayoutWithContext(title string, ctx context.Context) {
|
templ LayoutWithContext(title string, ctx context.Context) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="light h-full p-0 m-0">
|
<html lang="en" class="h-full bg-gray-50 dark:bg-gray-900">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||||
<meta name="theme-color" content="#2563eb"/>
|
<meta name="theme-color" content="#2563eb"/>
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
<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="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="32x32" href="/static/favicon-32x32.png"/>
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
|
||||||
<link rel="manifest" href="/static/site.webmanifest">
|
<link rel="manifest" href="/static/site.webmanifest"/>
|
||||||
<title>{ title } - GoMFT</title>
|
<title>{ title } - GoMFT</title>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<!-- Font Awesome -->
|
||||||
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
|
<link rel="stylesheet" href="/static/dist/fontawesome/css/all.min.css"/>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<!-- Application assets -->
|
||||||
<script src="/static/js/app.js"></script>
|
<link rel="stylesheet" href="/static/dist/app.css"/>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.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>
|
<script>
|
||||||
tailwind.config = {
|
// Immediate theme application
|
||||||
darkMode: 'class',
|
// This script runs before the DOM is fully loaded
|
||||||
theme: {
|
(function() {
|
||||||
extend: {
|
const isDark = localStorage.theme === 'dark' ||
|
||||||
colors: {
|
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
primary: {
|
|
||||||
50: '#f0f9ff',
|
if (isDark) {
|
||||||
100: '#e0f2fe',
|
document.documentElement.classList.add('dark');
|
||||||
200: '#bae6fd',
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
300: '#7dd3fc',
|
document.body.classList.add('dark');
|
||||||
400: '#38bdf8',
|
|
||||||
500: '#0ea5e9',
|
// Apply dark theme to containers
|
||||||
600: '#0284c7',
|
const containers = ['jobs-container', 'configs-container'];
|
||||||
700: '#0369a1',
|
containers.forEach(function(id) {
|
||||||
800: '#075985',
|
const container = document.getElementById(id);
|
||||||
900: '#0c4a6e',
|
if (container) {
|
||||||
950: '#082f49',
|
container.classList.add('dark');
|
||||||
},
|
container.style.backgroundColor = '#111827';
|
||||||
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>
|
</script>
|
||||||
<style type="text/tailwindcss">
|
<style>
|
||||||
/* Reset default browser margins and padding */
|
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden; /* Prevent horizontal scrolling */
|
overflow-x: hidden;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.btn-primary {
|
|
||||||
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
@apply px-4 py-2 bg-secondary-200 text-secondary-800 rounded-lg hover:bg-secondary-300 transition-all duration-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-secondary-300 focus:ring-offset-2 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
|
|
||||||
}
|
|
||||||
.btn-danger {
|
|
||||||
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
|
||||||
}
|
|
||||||
.btn-warning {
|
|
||||||
@apply px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2;
|
|
||||||
}
|
|
||||||
.form-input {
|
|
||||||
@apply w-full px-3 py-2 border border-secondary-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 shadow-sm transition-all duration-200 dark:bg-secondary-800 dark:border-secondary-700 dark:text-white;
|
|
||||||
}
|
|
||||||
.form-checkbox {
|
|
||||||
@apply rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:border-secondary-700 dark:bg-secondary-800;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
@apply bg-white dark:bg-secondary-800 rounded-xl shadow-custom overflow-hidden border border-secondary-200 dark:border-secondary-700 transition-all duration-200 hover:shadow-custom-lg;
|
|
||||||
}
|
|
||||||
.card-header {
|
|
||||||
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-900;
|
|
||||||
}
|
|
||||||
.card-body {
|
|
||||||
@apply p-6;
|
|
||||||
}
|
|
||||||
.badge {
|
|
||||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
|
||||||
}
|
|
||||||
.badge-success {
|
|
||||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
|
||||||
}
|
|
||||||
.badge-warning {
|
|
||||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100;
|
|
||||||
}
|
|
||||||
.badge-danger {
|
|
||||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
|
||||||
}
|
|
||||||
.badge-info {
|
|
||||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
|
||||||
}
|
|
||||||
.table-container {
|
|
||||||
@apply overflow-hidden rounded-lg shadow-sm border border-secondary-200 dark:border-secondary-700;
|
|
||||||
}
|
|
||||||
.table {
|
|
||||||
@apply min-w-full divide-y divide-secondary-200 dark:divide-secondary-700;
|
|
||||||
}
|
|
||||||
.table thead {
|
|
||||||
@apply bg-secondary-50 dark:bg-secondary-900;
|
|
||||||
}
|
|
||||||
.table th {
|
|
||||||
@apply px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider dark:text-secondary-400;
|
|
||||||
}
|
|
||||||
.table tbody {
|
|
||||||
@apply bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700;
|
|
||||||
}
|
|
||||||
.table td {
|
|
||||||
@apply px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-secondary-100;
|
|
||||||
}
|
|
||||||
.nav-link {
|
|
||||||
@apply inline-flex items-center px-1 pt-1 text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 border-b-2 border-transparent hover:border-primary-500 transition-all duration-200;
|
|
||||||
}
|
|
||||||
.nav-link-active {
|
|
||||||
@apply inline-flex items-center px-1 pt-1 text-primary-600 dark:text-primary-400 border-b-2 border-primary-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode styles */
|
|
||||||
.dark body {
|
|
||||||
@apply bg-secondary-900 text-secondary-100;
|
|
||||||
}
|
|
||||||
.dark .bg-white {
|
|
||||||
@apply bg-secondary-800;
|
|
||||||
}
|
|
||||||
.dark .text-secondary-800 {
|
|
||||||
@apply text-secondary-100;
|
|
||||||
}
|
|
||||||
.dark .text-secondary-700 {
|
|
||||||
@apply text-secondary-200;
|
|
||||||
}
|
|
||||||
.dark .text-secondary-500 {
|
|
||||||
@apply text-secondary-300;
|
|
||||||
}
|
|
||||||
.dark .hover\:text-secondary-700:hover {
|
|
||||||
@apply text-secondary-100;
|
|
||||||
}
|
|
||||||
.dark .hover\:bg-secondary-100:hover {
|
|
||||||
@apply bg-secondary-700;
|
|
||||||
}
|
|
||||||
.dark .bg-secondary-50 {
|
|
||||||
@apply bg-secondary-900;
|
|
||||||
}
|
|
||||||
.dark .bg-secondary-200 {
|
|
||||||
@apply bg-secondary-700;
|
|
||||||
}
|
|
||||||
.dark .shadow-sm {
|
|
||||||
@apply shadow-secondary-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional dark mode improvements */
|
|
||||||
.dark .bg-secondary-50 {
|
|
||||||
@apply bg-secondary-900;
|
|
||||||
}
|
|
||||||
.dark .bg-secondary-100 {
|
|
||||||
@apply bg-secondary-800;
|
|
||||||
}
|
|
||||||
.dark .border-secondary-200 {
|
|
||||||
@apply border-secondary-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom animations */
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fadeIn {
|
.animate-fadeIn {
|
||||||
animation: fadeIn 0.3s ease-in-out;
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
.pb-mobile-nav {
|
||||||
::-webkit-scrollbar {
|
padding-bottom: 4rem;
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: theme('colors.secondary.100');
|
@media (min-width: 640px) {
|
||||||
border-radius: 4px;
|
.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');
|
/* Force dark background on specific pages */
|
||||||
border-radius: 4px;
|
body.dark .configs-page,
|
||||||
|
body.dark .jobs-page {
|
||||||
|
background-color: #111827 !important; /* gray-900 */
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.dark ::-webkit-scrollbar-thumb {
|
|
||||||
background: theme('colors.secondary.600');
|
/* Ensure dark mode fills entire page */
|
||||||
|
body.dark {
|
||||||
|
background-color: #111827 !important;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: theme('colors.secondary.500');
|
/* Force container backgrounds */
|
||||||
|
#jobs-container.dark, #configs-container.dark {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: theme('colors.secondary.500');
|
/* Override any white backgrounds in dark mode */
|
||||||
|
body.dark .bg-white {
|
||||||
|
background-color: #1f2937 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full p-0 m-0">
|
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;" hx-on::after-swap="initFlowbite()">
|
||||||
if isLoggedIn(ctx) {
|
if isLoggedIn(ctx) {
|
||||||
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10 w-full">
|
<!-- Application Shell -->
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="flex justify-between h-16">
|
<!-- Desktop Sidebar -->
|
||||||
<div class="flex">
|
<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">
|
<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>
|
<i class="fas fa-exchange-alt mr-2"></i>
|
||||||
GoMFT
|
GoMFT
|
||||||
</a>
|
</a>
|
||||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
</div>
|
||||||
<a href="/dashboard" class="nav-link">
|
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
|
||||||
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
|
<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>
|
||||||
<a href="/configs" class="nav-link">
|
<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-1"></i> Configs
|
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Configs
|
||||||
</a>
|
</a>
|
||||||
<a href="/jobs" class="nav-link">
|
<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-1"></i> Jobs
|
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Jobs
|
||||||
</a>
|
</a>
|
||||||
<a href="/history" class="nav-link">
|
<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-1"></i> History
|
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
History
|
||||||
</a>
|
</a>
|
||||||
<a href="/files" class="nav-link">
|
<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-1"></i> Files
|
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Files
|
||||||
</a>
|
</a>
|
||||||
if isAdmin(ctx) {
|
if isAdmin(ctx) {
|
||||||
<a href="/admin/users" class="nav-link">
|
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
<i class="fas fa-users mr-1"></i> Users
|
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>
|
||||||
<a href="/admin/tools" class="nav-link">
|
<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-tools mr-1"></i> Admin Tools
|
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Roles
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
<div class="flex items-center space-x-4">
|
</div>
|
||||||
<!-- Theme Toggle -->
|
</aside>
|
||||||
<button
|
<!-- Mobile menu -->
|
||||||
id="theme-toggle"
|
<div class="md:hidden fixed top-0 z-30 w-full">
|
||||||
type="button"
|
<div class="bg-white dark:bg-gray-800 shadow-sm">
|
||||||
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"
|
<div class="flex items-center justify-between px-4 py-3">
|
||||||
onclick="toggleTheme()"
|
<a href="/" class="flex items-center text-primary-600 dark:text-primary-400">
|
||||||
>
|
<i class="fas fa-exchange-alt text-xl"></i>
|
||||||
<i class="fas fa-sun hidden dark:block"></i>
|
</a>
|
||||||
<i class="fas fa-moon block dark:hidden"></i>
|
<div class="flex items-center space-x-3">
|
||||||
</button>
|
<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">
|
||||||
|
|
||||||
<!-- 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')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-bars text-lg"></i>
|
<i class="fas fa-bars text-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
<!-- 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">
|
||||||
<!-- Mobile Navigation Menu -->
|
<div class="flex items-center justify-between">
|
||||||
<div
|
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
x-data="{ open: false }"
|
<i class="fas fa-exchange-alt mr-2"></i>
|
||||||
x-on:toggle-mobile-menu.window="open = !open"
|
GoMFT
|
||||||
x-show="open"
|
|
||||||
x-transition:enter="transition ease-out duration-200"
|
|
||||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
|
||||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
|
||||||
x-transition:leave="transition ease-in duration-150"
|
|
||||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
|
||||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
|
||||||
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md w-full left-0 right-0"
|
|
||||||
>
|
|
||||||
<div class="max-w-7xl mx-auto px-2 pt-2 pb-3 space-y-1">
|
|
||||||
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/configs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-cogs mr-2"></i> Configs
|
|
||||||
</a>
|
|
||||||
<a href="/jobs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-tasks mr-2"></i> Jobs
|
|
||||||
</a>
|
|
||||||
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-history mr-2"></i> History
|
|
||||||
</a>
|
|
||||||
<a href="/files" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-file-alt mr-2"></i> Files
|
|
||||||
</a>
|
|
||||||
if isAdmin(ctx) {
|
|
||||||
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
|
||||||
<i class="fas fa-users mr-2"></i> Users
|
|
||||||
</a>
|
</a>
|
||||||
<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">
|
<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-tools mr-2"></i> Admin Tools
|
<i class="fas fa-times"></i>
|
||||||
</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>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
<!-- Copy the same navigation items from the desktop sidebar -->
|
||||||
</div>
|
<nav class="mt-5">
|
||||||
}
|
<div class="space-y-1">
|
||||||
<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">
|
<!-- Copy the same links from the desktop sidebar -->
|
||||||
<!-- Hidden notification area for HTMX targets -->
|
<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">
|
||||||
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Dashboard
|
||||||
{ children... }
|
</a>
|
||||||
</main>
|
<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">
|
||||||
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
Configs
|
||||||
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
|
</a>
|
||||||
GoMFT © { getCurrentYear() } | Secure File Transfer Solution
|
<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">
|
||||||
</p>
|
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
<div class="flex justify-center items-center space-x-4 mt-1">
|
Jobs
|
||||||
<p class="text-xs text-secondary-400 dark:text-secondary-500">
|
</a>
|
||||||
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
|
<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="fab fa-github mr-1"></i>GitHub
|
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
</a>
|
History
|
||||||
</p>
|
</a>
|
||||||
<p class="text-xs text-secondary-400 dark:text-secondary-500">
|
<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">
|
||||||
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200" target="_blank" rel="noopener">
|
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||||
<i class="fas fa-tag mr-1"></i>Version { AppVersion }
|
Files
|
||||||
</a>
|
</a>
|
||||||
</p>
|
if isAdmin(ctx) {
|
||||||
</div>
|
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
</div>
|
Administration
|
||||||
</footer>
|
</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">
|
||||||
if isLoggedIn(ctx) {
|
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
<!-- Mobile Bottom Navigation -->
|
Users
|
||||||
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg w-full p-0 m-0">
|
</a>
|
||||||
<div class="grid grid-cols-6 h-16">
|
<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">
|
||||||
<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-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
<i class="fas fa-tachometer-alt text-lg"></i>
|
Roles
|
||||||
<span class="text-xs mt-1">Dashboard</span>
|
</a>
|
||||||
</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">
|
||||||
<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-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
<i class="fas fa-cogs text-lg"></i>
|
Audit Logs
|
||||||
<span class="text-xs mt-1">Configs</span>
|
</a>
|
||||||
</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">
|
||||||
<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-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
<i class="fas fa-tasks text-lg"></i>
|
Database Tools
|
||||||
<span class="text-xs mt-1">Jobs</span>
|
</a>
|
||||||
</a>
|
// Settings Dropdown
|
||||||
<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">
|
<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-history text-lg"></i>
|
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||||
<span class="text-xs mt-1">History</span>
|
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||||
</a>
|
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||||
<a href="/files" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||||
<i class="fas fa-file-alt text-lg"></i>
|
</svg>
|
||||||
<span class="text-xs mt-1">Files</span>
|
</button>
|
||||||
</a>
|
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||||
<button
|
// <li>
|
||||||
type="button"
|
// <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>
|
||||||
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
// </li>
|
||||||
x-data="{}"
|
<li>
|
||||||
@click="$dispatch('toggle-mobile-menu')"
|
<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>
|
||||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
Authentication Providers
|
||||||
<span class="text-xs mt-1">More</span>
|
</a>
|
||||||
</button>
|
</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>
|
</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>
|
||||||
|
<!-- Toast Container -->
|
||||||
|
@toast.Container()
|
||||||
|
<!-- Page Content -->
|
||||||
|
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="py-6 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Notification area -->
|
||||||
|
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
||||||
|
{ children... }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>GoMFT © { getCurrentYear() }</span>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fab fa-github"></i>
|
||||||
|
</a>
|
||||||
|
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-tag"></i> { AppVersion }
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Public page layout -->
|
||||||
|
<div class="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<main class="flex-grow">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>GoMFT © { getCurrentYear() }</span>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fab fa-github"></i>
|
||||||
|
</a>
|
||||||
|
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
|
<i class="fas fa-tag"></i> { AppVersion }
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Scripts -->
|
||||||
|
<!-- Alpine.js and dependencies -->
|
||||||
|
<script defer src="/static/dist/vendor.js"></script>
|
||||||
|
<!-- Shared Scripts -->
|
||||||
|
@toast.ShowToastJS()
|
||||||
|
<!-- Application scripts -->
|
||||||
|
<script defer src="/static/dist/app.js"></script>
|
||||||
|
<script defer src="/static/dist/init.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
@@ -536,17 +542,17 @@ func isLoggedIn(ctx context.Context) bool {
|
|||||||
func getUserInitial(ctx context.Context) string {
|
func getUserInitial(ctx context.Context) string {
|
||||||
// Try as string first
|
// Try as string first
|
||||||
if username, ok := ctx.Value("username").(string); ok && username != "" {
|
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)
|
// Try as interface{} (from JWT claims)
|
||||||
if username, ok := ctx.Value("username").(interface{}); ok {
|
if username, ok := ctx.Value("username").(interface{}); ok {
|
||||||
if strVal, ok := username.(string); ok && strVal != "" {
|
if strVal, ok := username.(string); ok && strVal != "" {
|
||||||
return string(strVal[0])
|
return strings.ToUpper(string(strVal[0]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try email as fallback
|
// Try email as fallback
|
||||||
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||||
return string(email[0])
|
return strings.ToUpper(string(email[0]))
|
||||||
}
|
}
|
||||||
return "U"
|
return "U"
|
||||||
}
|
}
|
||||||
@@ -555,12 +561,12 @@ func getUserInitial(ctx context.Context) string {
|
|||||||
func getUserEmail(ctx context.Context) string {
|
func getUserEmail(ctx context.Context) string {
|
||||||
// Try as string first
|
// Try as string first
|
||||||
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||||
return email
|
return strings.ToLower(email)
|
||||||
}
|
}
|
||||||
// Try as interface{} (from JWT claims)
|
// Try as interface{} (from JWT claims)
|
||||||
if email, ok := ctx.Value("email").(interface{}); ok {
|
if email, ok := ctx.Value("email").(interface{}); ok {
|
||||||
if strVal, ok := email.(string); ok && strVal != "" {
|
if strVal, ok := email.(string); ok && strVal != "" {
|
||||||
return strVal
|
return strings.ToLower(strVal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "user@example.com"
|
return "user@example.com"
|
||||||
|
|||||||
+117
-117
@@ -5,133 +5,133 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Login(ctx context.Context, errorMessage string) {
|
templ Login(ctx context.Context, errorMessage string, hasExternalProviders bool) {
|
||||||
@LayoutWithContext("Login", ctx) {
|
@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="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">
|
<div class="max-w-md w-full space-y-8">
|
||||||
<div class="card overflow-hidden shadow-lg">
|
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||||
<div class="p-8">
|
<div class="text-center mb-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">
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Sign In</h2>
|
||||||
if errorMessage != "" {
|
<p class="mt-2 text-gray-600 dark:text-gray-300">Access your GoMFT account</p>
|
||||||
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>
|
|
||||||
</div>
|
</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">
|
if errorMessage != "" {
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
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>
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
Contact an administrator to create an account
|
Contact an administrator to create an account
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
if hasExternalProviders {
|
||||||
|
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<div class="text-center">
|
||||||
<div class="mt-8 text-center">
|
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<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>
|
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||||
<span>Secure, encrypted connection</span>
|
<span>Secure, encrypted connection</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
// "strconv" // No longer needed here
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationDialog 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={ templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)} }>
|
||||||
|
{ confirmText }
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ).
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dialog
|
||||||
|
|
||||||
|
// DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog.
|
||||||
|
templ DialogScripts() {
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Called when the delete confirmation button is clicked.
|
||||||
|
// Primarily closes the modal; the actual delete is handled by hx-delete.
|
||||||
|
function triggerServiceDelete(dialogId, serviceId, serviceName) {
|
||||||
|
console.log(`Confirmed delete for service: ${serviceName} (ID: ${serviceId}). Closing modal: ${dialogId}`);
|
||||||
|
// Call the global closeModal function defined elsewhere (e.g., app.js)
|
||||||
|
if (typeof closeModal === 'function') {
|
||||||
|
closeModal(dialogId);
|
||||||
|
} else {
|
||||||
|
console.error('Global closeModal function not found.');
|
||||||
|
}
|
||||||
|
// Optional: Show a "Deleting..." toast here if desired.
|
||||||
|
// The hx-delete attribute on the button will trigger the actual backend request.
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// No utils needed for this specific template yet
|
||||||
|
)
|
||||||
|
|
||||||
|
templ EmailFields(data types.NotificationFormData) {
|
||||||
|
<!-- TODO: Populate value attributes if editing an email service -->
|
||||||
|
<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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ GotifyFields(data types.NotificationFormData) {
|
||||||
|
<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>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- 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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ NtfyFields(data types.NotificationFormData) {
|
||||||
|
<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>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- 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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ PushbulletFields(data types.NotificationFormData) {
|
||||||
|
<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>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- 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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ PushoverFields(data types.NotificationFormData) {
|
||||||
|
<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>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<!-- 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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||||
|
)
|
||||||
|
|
||||||
|
templ WebhookFields(data types.NotificationFormData) {
|
||||||
|
<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>
|
||||||
|
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||||
|
<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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/components" // For LayoutWithContext
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/form/utils"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/form/fields" // Import fields
|
||||||
|
)
|
||||||
|
|
||||||
|
templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
|
||||||
|
@FormScripts() // Include the form-specific scripts
|
||||||
|
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) {
|
||||||
|
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||||
|
|
||||||
|
<div id="notification-form-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<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>
|
||||||
|
{ utils.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="#notification-form-container">
|
||||||
|
<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 -->
|
||||||
|
@fields.EmailFields(data)
|
||||||
|
@fields.WebhookFields(data)
|
||||||
|
@fields.PushbulletFields(data)
|
||||||
|
@fields.NtfyFields(data)
|
||||||
|
@fields.GotifyFields(data)
|
||||||
|
@fields.PushoverFields(data)
|
||||||
|
|
||||||
|
|
||||||
|
<div class="mb-6 hidden common-fields">
|
||||||
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Select the job events that should trigger this notification.</p>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
for _, event := range []string{"job_start", "job_complete", "job_error"} {
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id={ "trigger_" + event }
|
||||||
|
name="event_triggers[]"
|
||||||
|
type="checkbox"
|
||||||
|
value={ event }
|
||||||
|
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 utils.IsEventTriggerSelected(data.NotificationService, event, data.IsNew) {
|
||||||
|
checked
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label for={ "trigger_" + event } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">{ utils.FormatEventTriggerName(event) }</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mb-6 hidden common-fields">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input type="hidden" name="is_enabled" value="false">
|
||||||
|
<input
|
||||||
|
id="is_enabled"
|
||||||
|
name="is_enabled"
|
||||||
|
type="checkbox"
|
||||||
|
value="true"
|
||||||
|
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"
|
||||||
|
if data.NotificationService != nil && data.NotificationService.IsEnabled {
|
||||||
|
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>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Check this box to make the service active.</p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Theme-specific background handled by Tailwind classes -->
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package form
|
||||||
|
|
||||||
|
// FormScripts contains JavaScript specific to the notification form page.
|
||||||
|
templ FormScripts() {
|
||||||
|
<script>
|
||||||
|
// Toggle notification fields based on selection
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const typeSelector = document.getElementById('notification_type');
|
||||||
|
// Ensure typeSelector exists before adding listener
|
||||||
|
if (!typeSelector) {
|
||||||
|
console.warn("Notification type selector not found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFields = document.querySelectorAll('.notification-fields');
|
||||||
|
const commonFields = document.querySelectorAll('.common-fields');
|
||||||
|
|
||||||
|
function toggleFields() {
|
||||||
|
// Hide all specific fields first
|
||||||
|
allFields.forEach(field => field.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Show/hide common fields based on selection
|
||||||
|
const selectedType = typeSelector.value;
|
||||||
|
if (selectedType) {
|
||||||
|
// Show common fields (name, description, is_enabled, submit)
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSelector.addEventListener('change', toggleFields);
|
||||||
|
|
||||||
|
// Initialize form state on load (if editing or if a type is pre-selected)
|
||||||
|
toggleFields();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetNotificationFormTitle returns the title for the notification form page.
|
||||||
|
func GetNotificationFormTitle(isNew bool) string {
|
||||||
|
if isNew {
|
||||||
|
return "Add Notification Service"
|
||||||
|
}
|
||||||
|
return "Edit Notification Service"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains checks if a string slice contains a specific string.
|
||||||
|
func Contains(slice []string, item string) bool {
|
||||||
|
for _, a := range slice {
|
||||||
|
if a == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoolToString converts a boolean to its string representation "true" or "false".
|
||||||
|
// Useful for setting HTML attributes that expect string values.
|
||||||
|
func BoolToString(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEventTriggerSelected checks if a specific event trigger should be pre-selected.
|
||||||
|
// It now accepts the anonymous struct type defined in types.NotificationFormData.
|
||||||
|
// Defaults to checking 'job_complete' and 'job_error' when creating a new service.
|
||||||
|
func IsEventTriggerSelected(service *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
|
||||||
|
}, event string, isNew bool) bool {
|
||||||
|
if !isNew && service != nil {
|
||||||
|
// Use the Contains helper function
|
||||||
|
return Contains(service.EventTriggers, event)
|
||||||
|
}
|
||||||
|
// Default for new services: check complete and error
|
||||||
|
return isNew && (event == "job_complete" || event == "job_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatEventTriggerName converts event trigger keys to human-readable names.
|
||||||
|
func FormatEventTriggerName(event string) string {
|
||||||
|
// Replace underscores with spaces and capitalize words
|
||||||
|
name := strings.ReplaceAll(event, "_", " ")
|
||||||
|
name = strings.Title(name) // Use strings.Title for capitalization
|
||||||
|
return name
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/components"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/dialog"
|
||||||
|
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List renders the notification services list page.
|
||||||
|
templ List(ctx context.Context, data types.SettingsNotificationsData) {
|
||||||
|
@components.LayoutWithContext("Notification Services", ctx) {
|
||||||
|
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||||
|
|
||||||
|
<div id="notifications-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||||
|
<div class="pb-8 w-full">
|
||||||
|
<!-- Success Message (hidden, used for HTMX responses/toast trigger) -->
|
||||||
|
if data.SuccessMessage != "" {
|
||||||
|
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||||
|
}
|
||||||
|
<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->
|
||||||
|
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 { // Default icon
|
||||||
|
<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 -->
|
||||||
|
@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={ templ.ComponentScript{Call: fmt.Sprintf("showModal('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 Placeholder -->
|
||||||
|
<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>
|
||||||
|
@dialog.DialogScripts()
|
||||||
|
|
||||||
|
}
|
||||||
|
// Script call removed for now
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package list
|
||||||
|
|
||||||
|
// ListScripts contains JavaScript specific to the notification list page.
|
||||||
|
templ ListScripts() {
|
||||||
|
<script>
|
||||||
|
// Notification system (showToast function is now in shared/toast/toast_js.templ)
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
// Ensure showToast is globally available
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
|
||||||
|
} else {
|
||||||
|
console.error("showToast function not found!");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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 specificErrorMsg = `Failed to delete notification service "${serviceName}"`;
|
||||||
|
|
||||||
|
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||||
|
// Try to provide a more specific error from the response
|
||||||
|
specificErrorMsg = `Error deleting "${serviceName}": ${event.detail.xhr.responseText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Showing error notification: ${specificErrorMsg}`);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(specificErrorMsg, 'error');
|
||||||
|
} else {
|
||||||
|
console.error("showToast function not found!");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Clear flags
|
||||||
|
window.currentlyDeletingService = false;
|
||||||
|
window.isServiceDeleteRequest = false;
|
||||||
|
window.lastDeletedService = null;
|
||||||
|
} else {
|
||||||
|
// General error toast
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
} else {
|
||||||
|
console.error("showToast function not found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle modal hide buttons (This might be better placed globally or in layout if modals are used elsewhere)
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// This listener handles closing modals via data-modal-hide attribute
|
||||||
|
// It might conflict or be redundant if Flowbite's JS handles this already.
|
||||||
|
// Consider removing if Flowbite is initialized globally.
|
||||||
|
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);
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById(modalId + "-backdrop");
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.add("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show any success or error messages passed via data struct as toasts
|
||||||
|
const successDiv = document.querySelector('.success-message');
|
||||||
|
if (successDiv) {
|
||||||
|
const successMsg = successDiv.textContent.trim();
|
||||||
|
if (successMsg && typeof showToast === 'function') {
|
||||||
|
showToast(successMsg, 'success');
|
||||||
|
} else if (successMsg) {
|
||||||
|
console.error("showToast function not found, cannot display success message:", successMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorDiv = document.querySelector('.error-message');
|
||||||
|
if (errorDiv) {
|
||||||
|
const errorMsg = errorDiv.textContent.trim();
|
||||||
|
if (errorMsg && typeof showToast === 'function') {
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
} else if (errorMsg) {
|
||||||
|
console.error("showToast function not found, cannot display error message:", errorMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Script for handling the service deletion trigger
|
||||||
|
function triggerServiceDelete(dialogId, serviceID, serviceName) {
|
||||||
|
// Hide the dialog first
|
||||||
|
closeModal(dialogId); // Reuse closeModal logic
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script for closing the modal
|
||||||
|
function closeModal(id) {
|
||||||
|
const dialog = document.getElementById(id);
|
||||||
|
if (dialog) {
|
||||||
|
dialog.classList.add("hidden");
|
||||||
|
dialog.classList.remove("flex");
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById(id + "-backdrop");
|
||||||
|
if (backdrop) {
|
||||||
|
// Instead of removing, hide it to potentially reuse
|
||||||
|
backdrop.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script for showing the modal
|
||||||
|
function showModal(id) {
|
||||||
|
const dialog = document.getElementById(id);
|
||||||
|
if (dialog) {
|
||||||
|
dialog.classList.remove("hidden");
|
||||||
|
dialog.classList.add("flex"); // Use flex to center content
|
||||||
|
}
|
||||||
|
const backdrop = document.getElementById(id + "-backdrop");
|
||||||
|
if (backdrop) {
|
||||||
|
backdrop.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
// SettingsNotificationsData defines the data needed for the notifications list page
|
||||||
|
type SettingsNotificationsData struct {
|
||||||
|
NotificationServices []NotificationServiceData
|
||||||
|
SuccessMessage string
|
||||||
|
ErrorMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationServiceData defines the data for a single service in the list
|
||||||
|
type NotificationServiceData struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
IsEnabled bool
|
||||||
|
Config map[string]string // Keep for now, might refine later
|
||||||
|
Description string
|
||||||
|
EventTriggers []string
|
||||||
|
PayloadTemplate string
|
||||||
|
SecretKey string
|
||||||
|
RetryPolicy string
|
||||||
|
SuccessCount int
|
||||||
|
FailureCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationFormData defines the data needed for the notification add/edit form
|
||||||
|
// TODO: This will be moved from notification_form.templ later
|
||||||
|
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
|
||||||
|
}
|
||||||
+161
-121
@@ -5,55 +5,53 @@ import (
|
|||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialog component for 2FA disable confirmation
|
// Dialog component for 2FA disable confirmation with Flowbite styling
|
||||||
templ TwoFactorDisableDialog() {
|
templ TwoFactorDisableDialog() {
|
||||||
<div id="disable-2fa-dialog" class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
<div 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="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
<div class="px-6 pt-5 pb-3 text-center">
|
<div class="relative bg-white rounded-lg shadow-xl dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex justify-center mb-2">
|
<div class="p-5 text-center">
|
||||||
<i class="fas fa-shield-alt text-yellow-400 text-3xl"></i>
|
<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">
|
||||||
</div>
|
<i class="fas fa-shield-alt text-yellow-500 dark:text-yellow-300 text-xl"></i>
|
||||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
</div>
|
||||||
Disable Two-Factor Authentication
|
<h3 class="mb-5 text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
</h3>
|
Disable Two-Factor Authentication
|
||||||
</div>
|
</h3>
|
||||||
<div class="px-6 py-4">
|
<p class="mb-6 text-gray-500 dark:text-gray-400">
|
||||||
<p class="text-secondary-700 dark:text-secondary-300 mb-4">
|
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
||||||
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
</p>
|
||||||
</p>
|
<div class="mb-6">
|
||||||
<div class="space-y-4">
|
<label for="current-password-2fa" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<div>
|
|
||||||
<label for="current-password-2fa" 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
|
<i class="fas fa-lock mr-1"></i> Current Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="current-password-2fa"
|
id="current-password-2fa"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
class="form-input pl-10 w-full"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Enter your current password"
|
placeholder="Enter your current password"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="disable-2fa-result"></div>
|
<div id="disable-2fa-result" class="mb-5"></div>
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||||
|
onclick="submitDisable2FA()">
|
||||||
|
<i class="fas fa-times mr-2"></i>
|
||||||
|
Disable 2FA
|
||||||
|
</button>
|
||||||
|
<button type="button" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" onclick="hideDisable2FADialog()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
|
||||||
<button type="button" class="btn-secondary" onclick="hideDisable2FADialog()">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
onclick="submitDisable2FA()">
|
|
||||||
<i class="fas fa-times mr-1"></i>
|
|
||||||
Disable 2FA
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -68,6 +66,7 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
// Global functions for dialog control
|
// Global functions for dialog control
|
||||||
window.hideDisable2FADialog = function() {
|
window.hideDisable2FADialog = function() {
|
||||||
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
||||||
|
document.getElementById('disable-2fa-dialog').classList.remove('flex');
|
||||||
document.getElementById('current-password-2fa').value = '';
|
document.getElementById('current-password-2fa').value = '';
|
||||||
document.getElementById('disable-2fa-result').innerHTML = '';
|
document.getElementById('disable-2fa-result').innerHTML = '';
|
||||||
};
|
};
|
||||||
@@ -75,14 +74,18 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
window.showDisable2FADialog = function() {
|
window.showDisable2FADialog = function() {
|
||||||
console.log('Showing 2FA disable dialog');
|
console.log('Showing 2FA disable dialog');
|
||||||
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
||||||
|
document.getElementById('disable-2fa-dialog').classList.add('flex');
|
||||||
};
|
};
|
||||||
|
|
||||||
window.submitDisable2FA = function() {
|
window.submitDisable2FA = function() {
|
||||||
const password = document.getElementById('current-password-2fa').value;
|
const password = document.getElementById('current-password-2fa').value;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
document.getElementById('disable-2fa-result').innerHTML = `
|
document.getElementById('disable-2fa-result').innerHTML = `
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded" role="alert">
|
<div class="p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||||
<span class="block sm:inline">Current password is required</span>
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span>Current password is required</span>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,64 +114,88 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
@TwoFactorDisableDialog()
|
@TwoFactorDisableDialog()
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<!-- Profile Header -->
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<i class="fas fa-user-circle 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-circle w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
Profile
|
Profile
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Profile Information Card -->
|
<!-- Profile Information Card -->
|
||||||
<div class="card">
|
<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="card-header">
|
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="flex items-center space-x-3">
|
||||||
<i class="fas fa-id-card mr-2 text-primary-500"></i>
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
Profile Information
|
<i class="fas fa-id-card text-blue-600 dark:text-blue-300"></i>
|
||||||
</h3>
|
</div>
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
|
<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>
|
||||||
<div class="card-body">
|
<div class="p-5">
|
||||||
<dl class="space-y-6">
|
<dl class="space-y-6">
|
||||||
<div class="flex flex-col sm:flex-row">
|
<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>
|
<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 text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">{ user.Email }</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row">
|
<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>
|
<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 text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||||
if user.GetIsAdmin() {
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="badge badge-success">
|
if user.GetIsAdmin() {
|
||||||
<i class="fas fa-user-shield mr-1"></i> Administrator
|
<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">
|
||||||
</span>
|
<i class="fas fa-user-shield w-3 h-3 mr-1.5"></i> Administrator
|
||||||
} else {
|
</span>
|
||||||
<span class="badge badge-info">
|
}
|
||||||
<i class="fas fa-user mr-1"></i> Regular User
|
for _, role := range user.Roles {
|
||||||
</span>
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row">
|
<div class="flex flex-col sm:flex-row">
|
||||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
<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 text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
<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 {
|
if user.TwoFactorEnabled {
|
||||||
<div class="flex flex-col space-y-3">
|
<div class="flex flex-col space-y-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="badge badge-success">
|
<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 mr-1"></i> Enabled
|
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Enabled
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<a href="/profile/2fa/backup-codes" class="btn-secondary btn-sm">
|
<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 mr-1"></i>
|
<i class="fas fa-key w-3 h-3 mr-1.5"></i>
|
||||||
Manage Backup Codes
|
Manage Backup Codes
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-danger btn-sm"
|
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800 shadow-sm"
|
||||||
onclick="showDisable2FADialog()">
|
onclick="showDisable2FADialog()">
|
||||||
<i class="fas fa-times mr-1"></i>
|
<i class="fas fa-times w-3 h-3 mr-1.5"></i>
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,13 +203,13 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
} else {
|
} else {
|
||||||
<div class="flex flex-col space-y-3">
|
<div class="flex flex-col space-y-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="badge badge-warning">
|
<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 mr-1"></i> Disabled
|
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Disabled
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mt-2">
|
||||||
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
|
<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 mr-1"></i>
|
<i class="fas fa-lock w-3 h-3 mr-1.5"></i>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,12 +218,12 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row">
|
<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>
|
<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 text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||||
<form
|
<form
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
class="flex items-center space-x-4">
|
class="flex items-center space-x-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -206,9 +233,9 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
checked?={ user.Theme == "light" || user.Theme == "" }
|
checked?={ user.Theme == "light" || user.Theme == "" }
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
class="form-checkbox" />
|
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 block text-sm text-secondary-700 dark:text-secondary-300">
|
<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"></i> Light
|
<i class="fas fa-sun mr-1.5"></i> Light
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -220,9 +247,9 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
checked?={ user.Theme == "dark" }
|
checked?={ user.Theme == "dark" }
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
class="form-checkbox" />
|
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 block text-sm text-secondary-700 dark:text-secondary-300">
|
<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"></i> Dark
|
<i class="fas fa-moon mr-1.5"></i> Dark
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -234,9 +261,9 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
checked?={ user.Theme == "system" }
|
checked?={ user.Theme == "system" }
|
||||||
hx-trigger="change"
|
hx-trigger="change"
|
||||||
hx-post="/profile/theme"
|
hx-post="/profile/theme"
|
||||||
class="form-checkbox" />
|
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 block text-sm text-secondary-700 dark:text-secondary-300">
|
<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"></i> System
|
<i class="fas fa-desktop mr-1.5"></i> System
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -247,15 +274,21 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change Password Card -->
|
<!-- Change Password Card -->
|
||||||
<div class="card">
|
<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="card-header">
|
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
<div class="flex items-center space-x-3">
|
||||||
<i class="fas fa-key mr-2 text-primary-500"></i>
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
Change Password
|
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||||
</h3>
|
</div>
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
|
<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>
|
||||||
<div class="card-body">
|
<div class="p-5">
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="/change-password"
|
action="/change-password"
|
||||||
@@ -264,7 +297,7 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-headers='{"X-Profile-Page": "true"}'
|
hx-headers='{"X-Profile-Page": "true"}'
|
||||||
hx-indicator="#password-change-indicator"
|
hx-indicator="#password-change-indicator"
|
||||||
class="space-y-4"
|
class="space-y-5"
|
||||||
x-data="{
|
x-data="{
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
@@ -283,57 +316,57 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
<div id="password-result"></div>
|
<div id="password-result"></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="current-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<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"></i> Current Password
|
<i class="fas fa-lock mr-1.5"></i> Current Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="current-password"
|
id="current-password"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
x-model="currentPassword"
|
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="••••••••"
|
placeholder="••••••••"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<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"></i> New Password
|
<i class="fas fa-lock-open mr-1.5"></i> New Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="new-password"
|
id="new-password"
|
||||||
name="new_password"
|
name="new_password"
|
||||||
x-model="newPassword"
|
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="••••••••"
|
placeholder="••••••••"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<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"></i> Confirm New Password
|
<i class="fas fa-check-double mr-1.5"></i> Confirm New Password
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
name="confirm_password"
|
name="confirm_password"
|
||||||
x-model="confirmPassword"
|
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="••••••••"
|
placeholder="••••••••"
|
||||||
required/>
|
required/>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,13 +375,13 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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">
|
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>
|
<i class="fas fa-save mr-2"></i>
|
||||||
Update Password
|
Update Password
|
||||||
</span>
|
</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">
|
<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>
|
<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>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
@@ -359,9 +392,11 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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="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-secondary-500 dark:text-secondary-400">
|
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
<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>
|
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,10 +404,15 @@ templ Profile(ctx context.Context, user db.User) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Notice -->
|
<!-- Security Notice -->
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8">
|
||||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
<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">
|
||||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
<div class="mr-3 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5">
|
||||||
<span>All profile changes are securely logged for your protection</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,66 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
templ NameField() {
|
templ NameField() {
|
||||||
<div class="space-y-6">
|
<div class="mb-6">
|
||||||
<div class="sm:col-span-4">
|
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
|
||||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Name</label>
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<i class="fas fa-tag text-gray-400 dark:text-gray-500"></i>
|
||||||
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input type="text" name="name" id="name" x-model="name" required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Transfer configuration name" />
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ FilePatternFields() {
|
templ FilePatternFields() {
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="file_pattern" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">File
|
<label for="file_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Pattern</label>
|
||||||
Pattern</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-filter text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-filter text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="*.txt, *.csv" />
|
placeholder="*.txt, *.csv" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Glob pattern for files to transfer. Leave empty to transfer all files.
|
Glob pattern for files to transfer. Leave empty to transfer all files.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="output_pattern"
|
<label for="output_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Output Pattern</label>
|
||||||
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Output Pattern</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-file-export text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-file-export text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="${filename}" />
|
placeholder="${filename}" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<div class="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||||
Pattern for filenames. Available variables:<br />
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Available variables:</h4>
|
||||||
${`filename`} - Original filename without extension (e.g., "report")<br />
|
<p class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
${`ext`} - Original file extension (e.g., "csv")<br />
|
<span class="block">• ${`filename`} - Original filename without extension (e.g., "report")</span>
|
||||||
${`date:format`} - Current date using Go's time format:<br />
|
<span class="block">• ${`ext`} - Original file extension (e.g., "csv")</span>
|
||||||
• 2006-01-02 → YYYY-MM-DD<br />
|
<span class="block">• ${`date:format`} - Current date using Go's time format:</span>
|
||||||
• 20060102 → YYYYMMDD<br />
|
<span class="block pl-4">- 2006-01-02 → YYYY-MM-DD</span>
|
||||||
• 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS<br />
|
<span class="block pl-4">- 20060102 → YYYYMMDD</span>
|
||||||
Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,163 +68,407 @@ templ FilePatternFields() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ ArchiveOptions() {
|
templ ArchiveOptions() {
|
||||||
<div class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-6 bg-secondary-50 dark:bg-secondary-800/50 shadow-sm">
|
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Archive & Delete Options</h3>
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center">
|
||||||
<div class="mb-4">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<label for="archive_enabled" class="flex items-center cursor-pointer">
|
<input type="checkbox" id="archive_enabled" name="archive_enabled" x-model="archiveEnabled"
|
||||||
<div class="relative">
|
class="sr-only peer" :value="archiveEnabled ? 'true' : 'false'">
|
||||||
<input id="archive_enabled" name="archive_enabled" type="checkbox" x-model="archiveEnabled"
|
<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>
|
||||||
class="sr-only"
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Enable archiving</span>
|
||||||
:value="archiveEnabled ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Enable archiving
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4" x-show="archiveEnabled">
|
<div x-show="archiveEnabled" class="mt-4">
|
||||||
<label for="archive_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Archive Path</label>
|
<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="relative">
|
||||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
|
||||||
</span>
|
</div>
|
||||||
<input id="archive_path" name="archive_path" type="text"
|
<input id="archive_path" name="archive_path" type="text" x-model="archivePath"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Path to archive files"
|
placeholder="Path to archive files" />
|
||||||
x-model="archivePath"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Files will be moved here after successful transfer
|
Files will be moved here after successful transfer
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="flex items-center">
|
||||||
<label for="delete_after_transfer" class="flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="delete_after_transfer" name="delete_after_transfer" x-model="deleteAfterTransfer"
|
||||||
<input id="delete_after_transfer" name="delete_after_transfer" type="checkbox" x-model="deleteAfterTransfer"
|
class="sr-only peer" :value="deleteAfterTransfer ? 'true' : 'false'">
|
||||||
class="sr-only"
|
<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>
|
||||||
:value="deleteAfterTransfer ? 'true' : 'false'"
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Delete source files after transfer</span>
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Delete source files after transfer
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
<p class="mt-1 ml-14 text-xs text-red-500 dark:text-red-400" x-show="deleteAfterTransfer">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div x-show="deleteAfterTransfer" class="mt-2">
|
||||||
<div class="mb-4">
|
<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">
|
||||||
<label for="skip_processed_files" class="flex items-center cursor-pointer">
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
<input id="skip_processed_files" name="skip_processed_files" type="checkbox" x-model="skipProcessedFiles"
|
<span>Warning: This will permanently delete the original files</span>
|
||||||
class="sr-only"
|
|
||||||
:value="skipProcessedFiles ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Skip files that have already been processed
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Files with the same hash that have been successfully processed before will be skipped
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label for="max_concurrent_transfers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Concurrent Transfers</label>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
|
||||||
class="form-range w-2/3 h-2 bg-secondary-200 dark:bg-secondary-700 rounded-lg appearance-none cursor-pointer"
|
|
||||||
min="1" max="32" step="1"
|
|
||||||
x-model="maxConcurrentTransfers"
|
|
||||||
/>
|
|
||||||
<span class="ml-4 text-secondary-700 dark:text-secondary-300" x-text="maxConcurrentTransfers"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</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)
|
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ RcloneFlags() {
|
templ RcloneFlags(currentCommandID uint) {
|
||||||
<div>
|
<div class="mb-6">
|
||||||
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300">Rclone Flags</label>
|
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
|
||||||
<div class="mt-1 relative rounded-md shadow-sm">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-flag text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
@RcloneCommandOptions(currentCommandID) // Pass it down
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Select the rclone command to use for this configuration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Flag container is now rendered directly in ConfigForm -->
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
|
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
@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" />
|
placeholder="--transfers 4 --checkers 8" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional: Additional rclone flags for fine-tuning the transfer.
|
Optional: Additional rclone flags for fine-tuning the transfer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New placeholder templ for rclone command options
|
||||||
|
templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
|
||||||
|
<div
|
||||||
|
hx-get="/api/rclone/commands"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"commandId": %d}`, currentCommandID) }>
|
||||||
|
<!-- Loading placeholder -->
|
||||||
|
<option value="">Loading commands...</option>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
templ SourceSelection() {
|
templ SourceSelection() {
|
||||||
<div class="sm:col-span-3">
|
<div class="mb-6">
|
||||||
<label for="source_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Source
|
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
|
||||||
Type</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<select id="source_type" name="source_type" x-model="sourceType"
|
<select id="source_type" name="source_type" x-model="sourceType"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
<option value="local">Local</option>
|
<option value="local">Local</option>
|
||||||
<option value="sftp">SFTP</option>
|
<option value="sftp">SFTP</option>
|
||||||
<option value="ftp">FTP</option>
|
<option value="ftp">FTP</option>
|
||||||
<option value="s3">S3</option>
|
<option value="s3">S3</option>
|
||||||
|
<option value="b2">Backblaze B2</option>
|
||||||
|
<option value="wasabi">Wasabi</option>
|
||||||
<option value="minio">MinIO</option>
|
<option value="minio">MinIO</option>
|
||||||
<option value="smb">SMB</option>
|
<option value="smb">SMB</option>
|
||||||
<option value="nextcloud">NextCloud</option>
|
<option value="nextcloud">NextCloud</option>
|
||||||
<option value="webdav">WebDAV</option>
|
<option value="webdav">WebDAV</option>
|
||||||
<option value="gdrive">Google Drive</option>
|
<option value="gdrive">Google Drive (BETA)</option>
|
||||||
<option value="gphotos">Google Photos</option>
|
<option value="gphotos">Google Photos (BETA)</option>
|
||||||
|
<option value="hetzner">Hetzner Storage Box</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ DestinationSelection() {
|
templ DestinationSelection() {
|
||||||
<div class="sm:col-span-3">
|
<div class="mb-6">
|
||||||
<label for="destination_type"
|
<label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label>
|
||||||
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Destination Type</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<select id="destination_type" name="destination_type" x-model="destinationType"
|
<select id="destination_type" name="destination_type" x-model="destinationType"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
<option value="local">Local</option>
|
<option value="local">Local</option>
|
||||||
<option value="sftp">SFTP</option>
|
<option value="sftp">SFTP</option>
|
||||||
<option value="ftp">FTP</option>
|
<option value="ftp">FTP</option>
|
||||||
<option value="s3">S3</option>
|
<option value="s3">S3</option>
|
||||||
|
<option value="b2">Backblaze B2</option>
|
||||||
|
<option value="wasabi">Wasabi</option>
|
||||||
<option value="minio">MinIO</option>
|
<option value="minio">MinIO</option>
|
||||||
<option value="smb">SMB</option>
|
<option value="smb">SMB</option>
|
||||||
<option value="nextcloud">NextCloud</option>
|
<option value="nextcloud">NextCloud</option>
|
||||||
<option value="webdav">WebDAV</option>
|
<option value="webdav">WebDAV</option>
|
||||||
<option value="gdrive">Google Drive</option>
|
<option value="gdrive">Google Drive (BETA)</option>
|
||||||
<option value="gphotos">Google Photos</option>
|
<option value="gphotos">Google Photos (BETA)</option>
|
||||||
|
<option value="hetzner">Hetzner Storage Box</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RcloneCommandOptionsContent renders the command options organized by category
|
||||||
|
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string, currentCommandID uint, commandFlagsJSON string, commandFlagValuesJSON string) { // Add flag JSON strings
|
||||||
|
<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']"
|
||||||
|
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
|
||||||
|
@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 {
|
||||||
|
if cmd.ID == currentCommandID {
|
||||||
|
<option value={ fmt.Sprintf("%d", cmd.ID) } selected>{ cmd.Name } - { cmd.Description }</option>
|
||||||
|
} else {
|
||||||
|
<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, selectedFlagsMap map[uint]bool, selectedFlagValues map[uint]string) {
|
||||||
|
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"
|
||||||
|
if selectedFlagsMap[flag.ID] {
|
||||||
|
checked
|
||||||
|
} />
|
||||||
|
<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)"
|
||||||
|
if selectedFlagsMap[flag.ID] {
|
||||||
|
checked
|
||||||
|
} />
|
||||||
|
<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, selectedFlagValues[flag.ID], selectedFlagsMap[flag.ID]) // Pass value and enabled status
|
||||||
|
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
|
||||||
|
// Initialization is now handled by server-side rendering
|
||||||
|
</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, value string, enabled bool) {
|
||||||
|
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 }
|
||||||
|
value={ value }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
} />
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
if !enabled {
|
||||||
|
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 }
|
||||||
|
value={ value }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
} />
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
if !enabled {
|
||||||
|
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 }
|
||||||
|
value={ value }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
} />
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
if !enabled {
|
||||||
|
disabled
|
||||||
|
}
|
||||||
|
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||||
|
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ B2DestinationForm() {
|
||||||
|
<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 Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
|
||||||
|
</div>
|
||||||
|
</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-b2-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Backblaze B2 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">Account 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_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="0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Account 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">Application 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="K0123456789abcdef0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Application Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (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-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.us-west-000.backblazeb2.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Custom endpoint URL (only needed for non-standard regions or private endpoints)
|
||||||
|
</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 create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,109 +1,81 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ FTPDestinationForm() {
|
templ FTPDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_host"
|
placeholder="ftp.example.com" />
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="ftp.example.com"/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||||
type="number"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_port"
|
placeholder="21" min="1" max="65535"
|
||||||
id="dest_port"
|
x-init="if (!destPort || destPort === 0) destPort = 21"
|
||||||
x-model="destPort"
|
x-effect="if (destinationType === 'ftp' && (destPort === 0 || destPort === 22)) destPort = 21" />
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="21"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="21"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="destination_path"
|
placeholder="username" />
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/destination"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_user"
|
placeholder="Enter password" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="dest_passive_mode" name="dest_passive_mode" x-model="destPassiveMode"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="destPassiveMode ? 'true' : 'false'">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||||
<input
|
</label>
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start mb-4">
|
<div>
|
||||||
<div class="flex items-center h-5">
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
<input
|
<div class="relative">
|
||||||
id="dest_passive_mode"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
name="dest_passive_mode"
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
type="checkbox"
|
</div>
|
||||||
x-model="destPassiveMode"
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||||
:value="destPassiveMode ? 'true' : 'false'"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
placeholder="/remote/path/to/files" />
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-sm">
|
|
||||||
<label for="dest_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
|
||||||
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,175 +1,106 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ GoogleDriveDestinationForm() {
|
templ GoogleDriveDestinationForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
|
<div class="space-y-6 mt-4">
|
||||||
if(value) {
|
<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">
|
||||||
destClientId = '';
|
<div class="flex items-center">
|
||||||
destClientSecret = '';
|
<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 class="mb-6">
|
</div>
|
||||||
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
|
|
||||||
<div class="relative">
|
|
||||||
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
x-model="useBuiltinAuthDest"
|
|
||||||
:value="useBuiltinAuthDest ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||||
</label>
|
<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>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||||
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"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
</div>
|
||||||
placeholder="Google Drive OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-show="!useBuiltinAuthDest">
|
||||||
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<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">
|
||||||
Google Client Secret
|
<div class="flex">
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
</label>
|
<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 class="relative">
|
</div>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</div>
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
|
||||||
placeholder="Google Drive OAuth Client Secret" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client Secret from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Google Drive Folder ID (optional)" />
|
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthDest" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||||
Folder ID to use as the root (leave empty for "My Drive")
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Shared/Team Drive ID (optional)" />
|
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthDest" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||||
ID of the Shared Drive / Team Drive to use
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div>
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-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 within Google Drive (e.g., /backup)" />
|
placeholder="/path/in/drive" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Path within the Drive where files will be uploaded
|
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div>
|
||||||
<div class="flex">
|
<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="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark: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>After saving this configuration, you'll need to authenticate with Google Drive:</p>
|
placeholder="Specific Google Drive ID" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div>
|
||||||
<div class="flex">
|
<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="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
<template x-if="useBuiltinAuthDest">
|
placeholder="Team Drive ID" />
|
||||||
<div>
|
</div>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
For accessing a shared team drive. Leave empty for personal drive.
|
||||||
<li>No need to create your own Google API credentials</li>
|
</p>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthDest">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Drive with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Drive API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
@@ -1,216 +1,188 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ GooglePhotosDestinationForm() {
|
templ GooglePhotosDestinationForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
|
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDest', value => {
|
||||||
if(value) {
|
if(value) {
|
||||||
destClientId = '';
|
destClientId = '';
|
||||||
destClientSecret = '';
|
destClientSecret = '';
|
||||||
}
|
}
|
||||||
})">
|
})">
|
||||||
<div class="mb-6">
|
<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">
|
||||||
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
|
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||||
class="sr-only"
|
</div>
|
||||||
x-model="useBuiltinAuthDest"
|
</div>
|
||||||
:value="useBuiltinAuthDest ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||||
</label>
|
<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>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
Simple one-click authentication using rclone's shared credentials
|
||||||
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"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
</div>
|
||||||
placeholder="Google Photos OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
||||||
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<div class="mb-6">
|
||||||
Google Client Secret
|
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
Google Client ID
|
||||||
</label>
|
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
<div class="relative">
|
</label>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="relative">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
</div>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||||
x-bind:disabled="useBuiltinAuthDest"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Google Photos OAuth Client Secret" />
|
x-bind:disabled="useBuiltinAuthDest"
|
||||||
</div>
|
placeholder="Google Photos OAuth Client ID" />
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</div>
|
||||||
Client Secret from Google Cloud Console
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
Client ID from Google Cloud Console
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_read_only" class="flex items-center cursor-pointer">
|
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<div class="relative">
|
Google Client Secret
|
||||||
<input id="dest_read_only" name="dest_read_only" type="checkbox"
|
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
class="sr-only"
|
</label>
|
||||||
x-model="destReadOnly"
|
<div class="relative">
|
||||||
:value="destReadOnly ? 'true' : 'false'"
|
<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 class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
</div>
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||||
:class="destReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark: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>
|
x-bind:disabled="useBuiltinAuthDest"
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
placeholder="Google Photos OAuth Client Secret" />
|
||||||
Read-only mode
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</label>
|
Client Secret from Google Cloud Console
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only request read-only access to your photos
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="dest_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="dest_read_only" name="dest_read_only" x-model="destReadOnly"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="destReadOnly ? 'true' : 'false'">
|
||||||
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||||
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only request read-only access to your photos
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only include photos uploaded after this year
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="dest_include_archived" class="flex items-center cursor-pointer">
|
<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="relative">
|
||||||
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
class="sr-only"
|
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||||
x-model="destIncludeArchived"
|
</div>
|
||||||
:value="destIncludeArchived ? 'true' : 'false'"
|
<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"
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
</div>
|
||||||
:class="destIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only include photos uploaded after this year
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
</p>
|
||||||
Include archived media
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Include archived photos and videos in media listings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="dest_include_archived" name="dest_include_archived" x-model="destIncludeArchived"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="destIncludeArchived ? 'true' : 'false'">
|
||||||
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Album path (e.g., album/my-photos)" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Include archived photos and videos in media listings
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Path within Google Photos where files will be uploaded.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div class="mb-6">
|
||||||
<div class="flex">
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark: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>After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
placeholder="Album path (e.g., album/my-photos)" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
Path within Google Photos to download files from
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div class="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">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Required</h3>
|
||||||
<div class="ml-3">
|
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
<li>Go to the configurations page</li>
|
||||||
<template x-if="useBuiltinAuthDest">
|
<li>Click the "Authenticate" button for this configuration</li>
|
||||||
<div>
|
<li>You'll be redirected to Google to grant access permissions</li>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
</ol>
|
||||||
<li>No need to create your own Google API credentials</li>
|
</div>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthDest">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Photos with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Photos API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
|
<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">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Information</h3>
|
||||||
<div class="ml-3">
|
<div class="mt-1">
|
||||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
|
<template x-if="useBuiltinAuthDest">
|
||||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
<div>
|
||||||
<p>All media items uploaded to Google Photos with rclone are stored in full resolution at original quality. These uploads will count towards storage in your Google Account.</p>
|
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||||
</div>
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
</div>
|
<li>No need to create your own Google API credentials</li>
|
||||||
</div>
|
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||||
</div>
|
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||||
</div>
|
</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,128 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ HetznerDestinationForm() {
|
||||||
|
<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 Hetzner Storage Box details below. You'll need your server details, username, and password.</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">Storage Box 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="uXXXXXX.your-storagebox.de" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<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-plug text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="dest_port" name="dest_port" x-model="destPort" 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="23" min="1" max="65535" value="23" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Connection port (default: 23)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex items-center me-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 me-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="uXXXXXX" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box username (typically matches your Storage Box number)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="destAuthType === 'password'" 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 && destAuthType === '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 ps-10 p-2.5 dark:bg-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 password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="destAuthType === 'key'" class="mb-6">
|
||||||
|
<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="destKeyFile" x-bind:required="requiresDestination && destAuthType === '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 ps-10 p-2.5 dark:bg-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" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your SSH private key file (must be readable by the application)
|
||||||
|
</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="/backups" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path on your Storage Box (e.g., /backups, /path/to/files)
|
||||||
|
</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">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,39 +1,31 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ LocalDestinationForm() {
|
templ LocalDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-4 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
required
|
|
||||||
:class="{ 'border-red-300 dark:border-red-700': destPathValid === false, 'border-green-300 dark:border-green-700': destPathValid === true }"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Local directory path (e.g., /path/to/destination)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Full path to the local directory where files will be saved
|
|
||||||
</p>
|
|
||||||
<!-- Path validation message -->
|
|
||||||
<template x-if="destPathValid !== null">
|
|
||||||
<p x-show="destPathError" class="mt-1 text-sm" :class="destPathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="destPathError"></p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary h-10 whitespace-nowrap"
|
|
||||||
@click="checkPath(destinationPath, 'dest')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
Check Location
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,113 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ MinIODestinationForm() {
|
templ MinIODestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="dest_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_endpoint"
|
|
||||||
id="dest_endpoint"
|
|
||||||
x-model="destEndpoint"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://minio.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_bucket"
|
placeholder="https://minio.example.com" />
|
||||||
id="dest_bucket"
|
|
||||||
x-model="destBucket"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
</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://)
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path/prefix/"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. If specified, files will be uploaded to this path in the bucket.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_access_key"
|
placeholder="my-bucket" />
|
||||||
id="dest_access_key"
|
|
||||||
x-model="destAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your MinIO bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
<div class="mb-6">
|
||||||
|
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_region" name="dest_region" x-model="destRegion"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-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">
|
||||||
|
Optional: Specify the region if your MinIO setup requires it.
|
||||||
|
</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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_secret_key"
|
|
||||||
id="dest_secret_key"
|
|
||||||
x-model="destSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,56 +1,82 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ NextCloudDestinationForm() {
|
templ NextCloudDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||||
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_host"
|
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://nextcloud.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_user"
|
placeholder="https://nextcloud.example.com" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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">
|
||||||
|
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="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="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files in NextCloud
|
||||||
|
</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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,91 +1,97 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ S3DestinationForm() {
|
templ S3DestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_bucket"
|
|
||||||
id="dest_bucket"
|
|
||||||
x-model="destBucket"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-globe-americas text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_region"
|
placeholder="us-east-1" />
|
||||||
id="dest_region"
|
|
||||||
x-model="destRegion"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="us-east-1"/>
|
|
||||||
</div>
|
</div>
|
||||||
</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)
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">S3 Path Prefix</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="optional/path/prefix/"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. If specified, files will be uploaded to this path in the bucket.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_access_key"
|
placeholder="my-bucket" />
|
||||||
id="dest_access_key"
|
|
||||||
x-model="destAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your S3 bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_secret_key"
|
|
||||||
id="dest_secret_key"
|
|
||||||
x-model="destSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,147 +1,97 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ SFTPDestinationForm() {
|
templ SFTPDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_host"
|
placeholder="sftp.example.com" />
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
aria-describedby="dest_host_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="sftp.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||||
Enter the SFTP server hostname or IP address.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||||
type="number"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_port"
|
placeholder="22" min="1" max="65535" x-init="if (!destPort || destPort === 0) destPort = 22" />
|
||||||
id="dest_port"
|
|
||||||
x-model="destPort"
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="22"
|
|
||||||
aria-describedby="dest_port_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="22"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||||
Default SFTP port is 22.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="destination_path"
|
placeholder="username" />
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
required
|
|
||||||
aria-describedby="destination_path_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||||
Absolute path to the files on the remote server.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||||
<div class="relative">
|
<div class="flex items-center mb-4">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
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">
|
||||||
</div>
|
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_user"
|
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
aria-describedby="dest_user_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<div class="flex items-center">
|
||||||
Username for SFTP authentication.
|
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
|
||||||
</p>
|
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">
|
||||||
</div>
|
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="dest_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
id="dest_auth_type"
|
|
||||||
name="dest_auth_type"
|
|
||||||
x-model="destAuthType"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
|
||||||
<option value="password">Password</option>
|
|
||||||
<option value="key_file">SSH Key File</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="destAuthType === 'password'">
|
<div x-show="destAuthType === 'password'">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword"
|
||||||
type="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 ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_password"
|
placeholder="Enter password" x-bind:required="destAuthType === 'password'" />
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
x-bind:required="destAuthType === 'password'"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="destAuthType === 'key_file'">
|
<div x-show="destAuthType === 'key'">
|
||||||
<label for="dest_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_key_file"
|
placeholder="/path/to/id_rsa" x-bind:required="destAuthType === 'key'" />
|
||||||
id="dest_key_file"
|
|
||||||
x-model="destKeyFile"
|
|
||||||
x-bind:required="destAuthType === 'key_file'"
|
|
||||||
aria-describedby="dest_key_file_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/key"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="dest_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||||
Absolute path to SSH private key file.
|
</div>
|
||||||
</p>
|
|
||||||
|
<div>
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" 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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,111 +1,112 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ SMBDestinationForm() {
|
templ SMBDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_host"
|
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="server.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_share"
|
placeholder="192.168.1.100 or server.example.com" />
|
||||||
id="dest_share"
|
|
||||||
x-model="destShare"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="share_name"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
IP address or hostname of the SMB/CIFS server
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="destination_path"
|
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path\to\destination"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. Subdirectory within the share.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_share" name="dest_share" x-model="destShare" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_domain"
|
placeholder="Documents" />
|
||||||
id="dest_domain"
|
|
||||||
x-model="destDomain"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="DOMAIN"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Windows domain for authentication.
|
Name of the shared folder on the server (without leading slashes)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_domain" name="dest_domain" x-model="destDomain"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_user"
|
placeholder="WORKGROUP or DOMAIN" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package destination
|
||||||
|
|
||||||
|
templ WasabiDestinationForm() {
|
||||||
|
<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 Wasabi Cloud Storage details below. You'll need your access key, secret key, region, 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">Wasabi 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">
|
||||||
|
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-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-wasabi-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Wasabi 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="ABCDEFGHIJKLMNOPQRST" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Access Key
|
||||||
|
</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="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Secret Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (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-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.wasabisys.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
|
||||||
|
</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 Wasabi bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,75 +1,82 @@
|
|||||||
package destination
|
package destination
|
||||||
|
|
||||||
templ WebDAVDestinationForm() {
|
templ WebDAVDestinationForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="dest_host"
|
|
||||||
id="dest_host"
|
|
||||||
x-model="destHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://webdav.example.com/remote.php/webdav/"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="destination_path"
|
placeholder="https://webdav.example.com" />
|
||||||
id="destination_path"
|
|
||||||
x-model="destinationPath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/destination"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Relative to the WebDAV URL.
|
Full URL to your WebDAV server including protocol (https://)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="dest_user"
|
placeholder="username" />
|
||||||
id="dest_user"
|
|
||||||
x-model="destUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV username
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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="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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="dest_password"
|
|
||||||
id="dest_password"
|
|
||||||
x-model="destPassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ templ ProviderForm(formType string, providers []string, isSource bool) {
|
|||||||
id={fmt.Sprintf("%s_provider", formType)}
|
id={fmt.Sprintf("%s_provider", formType)}
|
||||||
name={fmt.Sprintf("%s_provider", formType)}
|
name={fmt.Sprintf("%s_provider", formType)}
|
||||||
x-model={fmt.Sprintf("%sProvider", formType)}
|
x-model={fmt.Sprintf("%sProvider", formType)}
|
||||||
class="form-select pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
<option value="" disabled selected>Select provider type</option>
|
<option value="" disabled selected>Select provider type</option>
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
<option value={provider}>{providerDisplayName(provider)}</option>
|
<option value={provider}>{providerDisplayName(provider)}</option>
|
||||||
|
|||||||
@@ -1,367 +0,0 @@
|
|||||||
package providers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/common"
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/destination"
|
|
||||||
"github.com/starfleetcptn/gomft/components/providers/source"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Test that both source and destination providers can be rendered together with common components
|
|
||||||
func TestProvidersIntegration(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test rendering common components
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.NameField().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render NameField")
|
|
||||||
html := buf.String()
|
|
||||||
assert.Contains(html, `<label for="name"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test rendering source components
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render LocalSourceForm")
|
|
||||||
html := buf.String()
|
|
||||||
assert.Contains(html, `<label for="source_path"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test rendering destination components
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render LocalDestinationForm")
|
|
||||||
html := buf.String()
|
|
||||||
assert.Contains(html, `<label for="destination_path"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that all source providers are available
|
|
||||||
func TestSourceProviders(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test each source provider can be rendered
|
|
||||||
providers := []struct {
|
|
||||||
name string
|
|
||||||
template func() (string, error)
|
|
||||||
}{
|
|
||||||
{"LocalSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SFTPSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.SFTPSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"S3SourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.S3SourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"FTPSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.FTPSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SMBSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.SMBSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"WebDAVSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.WebDAVSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"GoogleDriveSourceForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.GoogleDriveSourceForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, provider := range providers {
|
|
||||||
t.Run(provider.name, func(t *testing.T) {
|
|
||||||
html, err := provider.template()
|
|
||||||
assert.NoError(err, "Failed to render "+provider.name)
|
|
||||||
assert.NotEmpty(html, provider.name+" rendered empty HTML")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that all destination providers are available
|
|
||||||
func TestDestinationProviders(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test each destination provider can be rendered
|
|
||||||
providers := []struct {
|
|
||||||
name string
|
|
||||||
template func() (string, error)
|
|
||||||
}{
|
|
||||||
{"LocalDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SFTPDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.SFTPDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"S3DestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.S3DestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"FTPDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.FTPDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"SMBDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.SMBDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"WebDAVDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.WebDAVDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
{"GoogleDriveDestinationForm", func() (string, error) {
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.GoogleDriveDestinationForm().Render(ctx, &buf)
|
|
||||||
return buf.String(), err
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, provider := range providers {
|
|
||||||
t.Run(provider.name, func(t *testing.T) {
|
|
||||||
html, err := provider.template()
|
|
||||||
assert.NoError(err, "Failed to render "+provider.name)
|
|
||||||
assert.NotEmpty(html, provider.name+" rendered empty HTML")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test the complete configuration wizard flow
|
|
||||||
func TestConfigurationWizard(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// First test common configuration fields
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.NameField().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render name field")
|
|
||||||
nameField := buf.String()
|
|
||||||
assert.Contains(nameField, `<input type="text" name="name" id="name"`)
|
|
||||||
|
|
||||||
// Test source selection
|
|
||||||
buf.Reset()
|
|
||||||
err = common.SourceSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render source selection")
|
|
||||||
sourceSelection := buf.String()
|
|
||||||
assert.Contains(sourceSelection, `<select id="source_type" name="source_type"`)
|
|
||||||
|
|
||||||
// Test specific source form (local example)
|
|
||||||
buf.Reset()
|
|
||||||
err = source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render local source form")
|
|
||||||
localSource := buf.String()
|
|
||||||
assert.Contains(localSource, `<input type="text" name="source_path" id="source_path"`)
|
|
||||||
|
|
||||||
// Test destination selection
|
|
||||||
buf.Reset()
|
|
||||||
err = common.DestinationSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render destination selection")
|
|
||||||
destinationSelection := buf.String()
|
|
||||||
assert.Contains(destinationSelection, `<select id="destination_type" name="destination_type"`)
|
|
||||||
|
|
||||||
// Test specific destination form (S3 example)
|
|
||||||
buf.Reset()
|
|
||||||
err = destination.S3DestinationForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render S3 destination form")
|
|
||||||
s3Destination := buf.String()
|
|
||||||
assert.Contains(s3Destination, `<input type="text" name="dest_bucket" id="dest_bucket"`)
|
|
||||||
|
|
||||||
// Test advanced options
|
|
||||||
buf.Reset()
|
|
||||||
err = common.ArchiveOptions().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render archive options")
|
|
||||||
archiveOptions := buf.String()
|
|
||||||
assert.Contains(archiveOptions, `Enable archiving`)
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
err = common.FilePatternFields().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render file pattern fields")
|
|
||||||
filePatterns := buf.String()
|
|
||||||
assert.Contains(filePatterns, `<input type="text" name="file_pattern" id="file_pattern"`)
|
|
||||||
|
|
||||||
// All essential components for the configuration wizard are present and renderable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that provider forms have proper conditional logic
|
|
||||||
func TestProviderFormConditionals(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test SFTP Source form conditionals (password vs key file)
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.SFTPSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render SFTP source form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have auth type selection
|
|
||||||
assert.Contains(html, `<select id="source_auth_type" name="source_auth_type"`)
|
|
||||||
|
|
||||||
// Should have password field that's conditionally shown
|
|
||||||
assert.Contains(html, `x-show="sourceAuthType === 'password'"`)
|
|
||||||
assert.Contains(html, `<input type="password" name="source_password"`)
|
|
||||||
|
|
||||||
// Should have key file field that's conditionally shown
|
|
||||||
assert.Contains(html, `x-show="sourceAuthType === 'key_file'"`)
|
|
||||||
assert.Contains(html, `<input type="text" name="source_key_file"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test S3 Source form conditionals
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.S3SourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render S3 source form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have both required and optional fields
|
|
||||||
assert.Contains(html, `<input type="text" name="source_bucket" id="source_bucket" x-model="sourceBucket" required`)
|
|
||||||
assert.Contains(html, `<input type="text" name="source_region" id="source_region"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test advanced options show/hide behavior
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.ArchiveOptions().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render archive options")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Archive path should only show when archive is enabled
|
|
||||||
assert.Contains(html, `x-show="archiveEnabled"`)
|
|
||||||
assert.Contains(html, `<input id="archive_path" name="archive_path" type="text"`)
|
|
||||||
|
|
||||||
// Toggle behavior
|
|
||||||
assert.Contains(html, `x-model="archiveEnabled"`)
|
|
||||||
assert.Contains(html, `<input id="archive_enabled" name="archive_enabled" type="checkbox"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test for accessibility attributes in provider forms
|
|
||||||
func TestProviderFormsAccessibility(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test source form for accessibility
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render local source form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have labels with proper for attributes
|
|
||||||
assert.Contains(html, `<label for="source_path"`)
|
|
||||||
|
|
||||||
// Should have input with id matching label's for attribute
|
|
||||||
assert.Contains(html, `<input type="text" name="source_path" id="source_path"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test destination form for accessibility
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := destination.LocalDestinationForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render local destination form")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have labels with proper for attributes
|
|
||||||
assert.Contains(html, `<label for="destination_path"`)
|
|
||||||
|
|
||||||
// Should have input with id matching label's for attribute
|
|
||||||
assert.Contains(html, `<input type="text" name="destination_path" id="destination_path"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test dynamic form rendering based on provider selection
|
|
||||||
func TestDynamicFormRendering(t *testing.T) {
|
|
||||||
// Create context for test
|
|
||||||
ctx := context.Background()
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
// Test source selection dynamic rendering
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.SourceSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render source selection")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have x-model for binding selected value
|
|
||||||
assert.Contains(html, `x-model="sourceType"`)
|
|
||||||
|
|
||||||
// The source selection component doesn't contain x-show attributes
|
|
||||||
// These assertions are removed as they're not part of the actual component
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test destination selection dynamic rendering
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := common.DestinationSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err, "Failed to render destination selection")
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Should have x-model for binding selected value
|
|
||||||
assert.Contains(html, `x-model="destinationType"`)
|
|
||||||
|
|
||||||
// The destination selection component doesn't contain x-show attributes
|
|
||||||
// These assertions are removed as they're not part of the actual component
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test for proper Alpine.js initialization
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
err := source.LocalSourceForm().Render(ctx, &buf)
|
|
||||||
assert.NoError(err)
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// The LocalSourceForm doesn't initialize Alpine.js data
|
|
||||||
// It's expected to be used within a parent component that does
|
|
||||||
assert.Contains(html, `x-model="sourcePath"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that wizard has a submission handler
|
|
||||||
{
|
|
||||||
var buf strings.Builder
|
|
||||||
// The source selection component doesn't contain form tags
|
|
||||||
// These assertions are checking for elements that should be in a parent component
|
|
||||||
err := common.SourceSelection().Render(ctx, &buf)
|
|
||||||
assert.NoError(err)
|
|
||||||
html := buf.String()
|
|
||||||
|
|
||||||
// Check for the select element instead
|
|
||||||
assert.Contains(html, `<select id="source_type" name="source_type"`)
|
|
||||||
assert.Contains(html, `x-model="sourceType"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ B2SourceForm() {
|
||||||
|
<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 Backblaze B2 details below. You'll need your account ID, application key, and bucket information.</span>
|
||||||
|
</div>
|
||||||
|
</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-b2-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Backblaze B2 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">Account 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_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="0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Account 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">Application 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="K0123456789abcdef0123456789abcdef0123456789" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Backblaze B2 Application Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (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-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.us-west-000.backblazeb2.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Custom endpoint URL (only needed for non-standard regions or private endpoints)
|
||||||
|
</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 create an application key with restricted permissions for this configuration. The application key should only have access to the specific B2 bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,109 +1,79 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ FTPSourceForm() {
|
templ FTPSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_host"
|
placeholder="ftp.example.com" />
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="ftp.example.com"/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||||
type="number"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_port"
|
placeholder="21" min="1" max="65535" value="21"/>
|
||||||
id="source_port"
|
|
||||||
x-model="sourcePort"
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="21"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="21"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_path"
|
placeholder="username" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_user"
|
placeholder="Enter password" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="source_passive_mode" name="source_passive_mode" x-model="sourcePassiveMode"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="sourcePassiveMode ? 'true' : 'false'">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||||
<input
|
</label>
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start mb-4">
|
<div>
|
||||||
<div class="flex items-center h-5">
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
<input
|
<div class="relative">
|
||||||
id="source_passive_mode"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
name="source_passive_mode"
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
type="checkbox"
|
</div>
|
||||||
x-model="sourcePassiveMode"
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
:value="sourcePassiveMode ? 'true' : 'false'"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
placeholder="/remote/path/to/files" />
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-sm">
|
|
||||||
<label for="source_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
|
||||||
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,175 +1,106 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ GoogleDriveSourceForm() {
|
templ GoogleDriveSourceForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
|
<div class="space-y-6 mt-4">
|
||||||
if(value) {
|
<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">
|
||||||
sourceClientId = '';
|
<div class="flex items-center">
|
||||||
sourceClientSecret = '';
|
<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 class="mb-6">
|
</div>
|
||||||
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
|
|
||||||
<div class="relative">
|
|
||||||
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
x-model="useBuiltinAuthSource"
|
|
||||||
:value="useBuiltinAuthSource ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||||
</label>
|
<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>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||||
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"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
</div>
|
||||||
placeholder="Google Drive OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-show="!useBuiltinAuthSource">
|
||||||
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<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">
|
||||||
Google Client Secret
|
<div class="flex">
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
</label>
|
<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 class="relative">
|
</div>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</div>
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
|
||||||
placeholder="Google Drive OAuth Client Secret" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client Secret from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
|
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Google Drive Folder ID (optional)" />
|
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthSource" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||||
Folder ID to use as the root (leave empty for "My Drive")
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
|
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Shared/Team Drive ID (optional)" />
|
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthSource" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||||
ID of the Shared Drive / Team Drive to use
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-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 within Google Drive (e.g., /backup)" />
|
placeholder="/path/in/drive" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Path within the Drive from which files will be transferred
|
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div>
|
||||||
<div class="flex">
|
<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="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark: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>After saving this configuration, you'll need to authenticate with Google Drive:</p>
|
placeholder="Specific Google Drive ID" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div>
|
||||||
<div class="flex">
|
<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="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
<template x-if="useBuiltinAuthSource">
|
placeholder="Team Drive ID" />
|
||||||
<div>
|
</div>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
For accessing a shared team drive. Leave empty for personal drive.
|
||||||
<li>No need to create your own Google API credentials</li>
|
</p>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthSource">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Drive with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Drive API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
@@ -1,216 +1,188 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ GooglePhotosSourceForm() {
|
templ GooglePhotosSourceForm() {
|
||||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
|
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthSource', value => {
|
||||||
if(value) {
|
if(value) {
|
||||||
sourceClientId = '';
|
sourceClientId = '';
|
||||||
sourceClientSecret = '';
|
sourceClientSecret = '';
|
||||||
}
|
}
|
||||||
})">
|
})">
|
||||||
<div class="mb-6">
|
<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">
|
||||||
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
|
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||||
class="sr-only"
|
</div>
|
||||||
x-model="useBuiltinAuthSource"
|
</div>
|
||||||
:value="useBuiltinAuthSource ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
|
||||||
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Use rclone's built-in Google authentication (recommended)
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Simple one-click authentication using rclone's shared credentials
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
<div>
|
||||||
<div>
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
Google Client ID
|
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||||
</label>
|
<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>
|
||||||
<div class="relative">
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
</label>
|
||||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
Simple one-click authentication using rclone's shared credentials
|
||||||
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"
|
</p>
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
</div>
|
||||||
placeholder="Google Photos OAuth Client ID" />
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Client ID from Google Cloud Console
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
||||||
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<div class="mb-6">
|
||||||
Google Client Secret
|
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
Google Client ID
|
||||||
</label>
|
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
<div class="relative">
|
</label>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="relative">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
</div>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||||
x-bind:disabled="useBuiltinAuthSource"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
placeholder="Google Photos OAuth Client Secret" />
|
x-bind:disabled="useBuiltinAuthSource"
|
||||||
</div>
|
placeholder="Google Photos OAuth Client ID" />
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</div>
|
||||||
Client Secret from Google Cloud Console
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
Client ID from Google Cloud Console
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_read_only" class="flex items-center cursor-pointer">
|
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<div class="relative">
|
Google Client Secret
|
||||||
<input id="source_read_only" name="source_read_only" type="checkbox"
|
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||||
class="sr-only"
|
</label>
|
||||||
x-model="sourceReadOnly"
|
<div class="relative">
|
||||||
:value="sourceReadOnly ? 'true' : 'false'"
|
<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 class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
</div>
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||||
:class="sourceReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark: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>
|
x-bind:disabled="useBuiltinAuthSource"
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
placeholder="Google Photos OAuth Client Secret" />
|
||||||
Read-only mode
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</label>
|
Client Secret from Google Cloud Console
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only request read-only access to your photos
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="source_read_only" name="source_read_only" x-model="sourceReadOnly"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="sourceReadOnly ? 'true' : 'false'">
|
||||||
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||||
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only request read-only access to your photos
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Only include photos uploaded after this year
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mb-6">
|
||||||
<label for="source_include_archived" class="flex items-center cursor-pointer">
|
<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="relative">
|
||||||
<input id="source_include_archived" name="source_include_archived" type="checkbox"
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
class="sr-only"
|
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||||
x-model="sourceIncludeArchived"
|
</div>
|
||||||
:value="sourceIncludeArchived ? 'true' : 'false'"
|
<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"
|
||||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
</div>
|
||||||
:class="sourceIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Only include photos uploaded after this year
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
</p>
|
||||||
Include archived media
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Include archived photos and videos in media listings
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="flex items-center mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="source_include_archived" name="source_include_archived" x-model="sourceIncludeArchived"
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
class="sr-only peer" :value="sourceIncludeArchived ? 'true' : 'false'">
|
||||||
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
</div>
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
</label>
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
</div>
|
||||||
placeholder="Album path (e.g., album/my-photos)" />
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Include archived photos and videos in media listings
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</p>
|
||||||
Path within Google Photos to download files from
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
<div class="mb-6">
|
||||||
<div class="flex">
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||||
<div class="flex-shrink-0">
|
<div class="relative">
|
||||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
</div>
|
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||||
<div class="ml-3">
|
</div>
|
||||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark: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>After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
placeholder="Album path (e.g., album/my-photos)" />
|
||||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
</div>
|
||||||
<li>Go to the configurations page</li>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<li>Click the "Authenticate" button for this configuration</li>
|
Path within Google Photos to download files from
|
||||||
<li>You'll be redirected to Google to grant access permissions</li>
|
</p>
|
||||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
</div>
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
<div class="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">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Required</h3>
|
||||||
<div class="ml-3">
|
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
<li>Go to the configurations page</li>
|
||||||
<template x-if="useBuiltinAuthSource">
|
<li>Click the "Authenticate" button for this configuration</li>
|
||||||
<div>
|
<li>You'll be redirected to Google to grant access permissions</li>
|
||||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
</ol>
|
||||||
<li>No need to create your own Google API credentials</li>
|
</div>
|
||||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
</div>
|
||||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
</div>
|
||||||
</ul>
|
|
||||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
||||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
|
||||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template x-if="!useBuiltinAuthSource">
|
|
||||||
<div>
|
|
||||||
<p>To use Google Photos with your own credentials:</p>
|
|
||||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
|
||||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
|
||||||
<li>Create a project and enable the Google Photos API</li>
|
|
||||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
|
||||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
|
<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">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||||
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
|
<div>
|
||||||
</div>
|
<h3 class="font-medium">Authentication Information</h3>
|
||||||
<div class="ml-3">
|
<div class="mt-1">
|
||||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
|
<template x-if="useBuiltinAuthSource">
|
||||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
<div>
|
||||||
<p>When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||||
</div>
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
</div>
|
<li>No need to create your own Google API credentials</li>
|
||||||
</div>
|
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||||
</div>
|
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||||
</div>
|
</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>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ HetznerSourceForm() {
|
||||||
|
<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 Hetzner Storage Box details below. You'll need your server details, username, and password.</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">Storage Box 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="uXXXXXX.your-storagebox.de" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box hostname (e.g., uXXXXXX.your-storagebox.de)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<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-plug text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="source_port" name="source_port" x-model="sourcePort" 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="23" min="1" max="65535" value="23" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Connection port (default: 23)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_auth_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Type</label>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex items-center me-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 me-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="uXXXXXX" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box username (typically matches your Storage Box number)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="sourceAuthType === 'password'" 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" x-bind:required="sourceAuthType === '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 ps-10 p-2.5 dark:bg-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 password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Hetzner Storage Box password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="sourceAuthType === 'key'" class="mb-6">
|
||||||
|
<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" x-bind:required="sourceAuthType === '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 ps-10 p-2.5 dark:bg-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" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your SSH private key file (must be readable by the application)
|
||||||
|
</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="/backups" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path on your Storage Box (e.g., /backups, /path/to/files)
|
||||||
|
</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">For increased security, consider using SSH key authentication instead of password authentication when possible.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,39 +1,31 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ LocalSourceForm() {
|
templ LocalSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-4 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_path"
|
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
required
|
|
||||||
:class="{ 'border-red-300 dark:border-red-700': sourcePathValid === false, 'border-green-300 dark:border-green-700': sourcePathValid === true }"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Local directory path (e.g., /path/to/files)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Full path to the local directory containing your files
|
|
||||||
</p>
|
|
||||||
<!-- Path validation message -->
|
|
||||||
<template x-if="sourcePathValid !== null">
|
|
||||||
<p x-show="sourcePathError" class="mt-1 text-sm" :class="sourcePathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="sourcePathError"></p>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary h-10 whitespace-nowrap"
|
|
||||||
@click="checkPath(sourcePath, 'source')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
Check Location
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,113 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ MinIOSourceForm() {
|
templ MinIOSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="source_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_endpoint"
|
|
||||||
id="source_endpoint"
|
|
||||||
x-model="sourceEndpoint"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://minio.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_bucket"
|
placeholder="https://minio.example.com" />
|
||||||
id="source_bucket"
|
|
||||||
x-model="sourceBucket"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
</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://)
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_path"
|
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path/prefix/"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. If specified, only files in this path will be processed.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_access_key"
|
placeholder="my-bucket" />
|
||||||
id="source_access_key"
|
|
||||||
x-model="sourceAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your MinIO bucket (case-sensitive)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
<div class="mb-6">
|
||||||
|
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (Optional)</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_region" name="source_region" x-model="sourceRegion"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-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">
|
||||||
|
Optional: Specify the region if your MinIO setup requires it.
|
||||||
|
</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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_secret_key"
|
|
||||||
id="source_secret_key"
|
|
||||||
x-model="sourceSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_secret_key" :value="sourceSecretKey"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,75 +1,82 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ NextCloudSourceForm() {
|
templ NextCloudSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||||
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_host"
|
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://nextcloud.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_path"
|
placeholder="https://nextcloud.example.com" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/remote.php/dav/files/username/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. If left empty, root directory will be used.
|
Full URL to your NextCloud server including protocol (https://)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_user"
|
placeholder="username" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud username
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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="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="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files in NextCloud
|
||||||
|
</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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,100 +1,96 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ S3SourceForm() {
|
templ S3SourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||||
<i class="fab fa-aws text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_bucket"
|
|
||||||
id="source_bucket"
|
|
||||||
x-model="sourceBucket"
|
|
||||||
required
|
|
||||||
aria-describedby="source_bucket_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="my-bucket"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_bucket_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
</div>
|
||||||
Enter your S3 bucket name.
|
|
||||||
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_region"
|
placeholder="my-bucket" />
|
||||||
id="source_region"
|
|
||||||
x-model="sourceRegion"
|
|
||||||
required
|
|
||||||
aria-describedby="source_region_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="us-west-2"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_region_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
AWS region for the S3 bucket (e.g., us-west-2).
|
Name of your S3 bucket (case-sensitive)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_path"
|
placeholder="AKIAIOSFODNN7EXAMPLE" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
aria-describedby="source_path_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path/to/files/"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional path prefix within the bucket (e.g., 'path/to/files/').
|
Your AWS Access Key ID
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key ID</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_access_key"
|
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
|
||||||
id="source_access_key"
|
|
||||||
x-model="sourceAccessKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="AKIAIOSFODNN7EXAMPLE"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your AWS Secret Access Key
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Access Key</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_secret_key"
|
|
||||||
id="source_secret_key"
|
|
||||||
x-model="sourceSecretKey"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Your secret access key"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,147 +1,97 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ SFTPSourceForm() {
|
templ SFTPSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_host"
|
placeholder="sftp.example.com" />
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
aria-describedby="source_host_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="sftp.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||||
Enter the SFTP server hostname or IP address.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||||
type="number"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_port"
|
placeholder="22" min="1" max="65535" />
|
||||||
id="source_port"
|
|
||||||
x-model="sourcePort"
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="22"
|
|
||||||
aria-describedby="source_port_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="22"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||||
Default SFTP port is 22.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_path"
|
placeholder="username" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
required
|
|
||||||
aria-describedby="source_path_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||||
Absolute path to the files on the remote server.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div>
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||||
<div class="relative">
|
<div class="flex items-center mb-4">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
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">
|
||||||
</div>
|
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_user"
|
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
aria-describedby="source_user_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<div class="flex items-center">
|
||||||
Username for SFTP authentication.
|
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
|
||||||
</p>
|
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">
|
||||||
</div>
|
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
id="source_auth_type"
|
|
||||||
name="source_auth_type"
|
|
||||||
x-model="sourceAuthType"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
|
||||||
<option value="password">Password</option>
|
|
||||||
<option value="key_file">SSH Key File</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="sourceAuthType === 'password'">
|
<div x-show="sourceAuthType === 'password'">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="password" id="source_password" name="source_password" x-model="sourcePassword"
|
||||||
type="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 ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_password"
|
placeholder="Enter password" x-bind:required="sourceAuthType === 'password'" />
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
x-bind:required="sourceAuthType === 'password'"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4" x-show="sourceAuthType === 'key_file'">
|
<div x-show="sourceAuthType === 'key'">
|
||||||
<label for="source_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_key_file"
|
placeholder="/path/to/id_rsa" x-bind:required="sourceAuthType === 'key'" />
|
||||||
id="source_key_file"
|
|
||||||
x-model="sourceKeyFile"
|
|
||||||
x-bind:required="sourceAuthType === 'key_file'"
|
|
||||||
aria-describedby="source_key_file_help"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/key"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p id="source_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||||
Absolute path to SSH private key file.
|
</div>
|
||||||
</p>
|
|
||||||
|
<div>
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/remote/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1,111 +1,112 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ SMBSourceForm() {
|
templ SMBSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_host"
|
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="server.example.com"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_share"
|
placeholder="192.168.1.100 or server.example.com" />
|
||||||
id="source_share"
|
|
||||||
x-model="sourceShare"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="share_name"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
IP address or hostname of the SMB/CIFS server
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_path"
|
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="path\to\files"/>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
Optional. Subdirectory within the share.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_share" name="source_share" x-model="sourceShare" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_domain"
|
placeholder="Documents" />
|
||||||
id="source_domain"
|
|
||||||
x-model="sourceDomain"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="DOMAIN"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Windows domain for authentication.
|
Name of the shared folder on the server (without leading slashes)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_domain" name="source_domain" x-model="sourceDomain"
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_user"
|
placeholder="WORKGROUP or DOMAIN" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
templ WasabiSourceForm() {
|
||||||
|
<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 Wasabi Cloud Storage details below. You'll need your access key, secret key, region, 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">Wasabi 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">
|
||||||
|
Wasabi region where your bucket is located (e.g., us-east-1, us-west-1, eu-central-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-wasabi-bucket" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Name of your Wasabi 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="ABCDEFGHIJKLMNOPQRST" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Access Key
|
||||||
|
</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="abcdefghijklmnopqrstuvwxyz1234567890ABCD" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your Wasabi Secret Key
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint (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-server text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="s3.wasabisys.com" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Wasabi endpoint URL (usually s3.wasabisys.com or region-specific endpoints)
|
||||||
|
</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 Wasabi bucket and operations needed.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -1,75 +1,82 @@
|
|||||||
package source
|
package source
|
||||||
|
|
||||||
templ WebDAVSourceForm() {
|
templ WebDAVSourceForm() {
|
||||||
<div class="sm:col-span-6 space-y-6">
|
<div class="space-y-6 mt-4">
|
||||||
<div class="sm:col-span-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">
|
||||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
<div class="flex items-center">
|
||||||
<div class="relative">
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="source_host"
|
|
||||||
id="source_host"
|
|
||||||
x-model="sourceHost"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="https://webdav.example.com/remote.php/webdav/"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_path"
|
placeholder="https://webdav.example.com" />
|
||||||
id="source_path"
|
|
||||||
x-model="sourcePath"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="/path/to/files"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Optional. Relative to the WebDAV URL.
|
Full URL to your WebDAV server including protocol (https://)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||||
type="text"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
name="source_user"
|
placeholder="username" />
|
||||||
id="source_user"
|
|
||||||
x-model="sourceUser"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV username
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
<div class="mb-6">
|
||||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<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="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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="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>
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="source_password"
|
|
||||||
id="source_password"
|
|
||||||
x-model="sourcePassword"
|
|
||||||
required
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="Password"/>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
+151
-81
@@ -6,98 +6,168 @@ import (
|
|||||||
|
|
||||||
templ Register(ctx context.Context, errorMessage string) {
|
templ Register(ctx context.Context, errorMessage string) {
|
||||||
@LayoutWithContext("Register", ctx) {
|
@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="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-md w-full space-y-8">
|
<!-- Page Header -->
|
||||||
<div>
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
Create a new user account
|
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
</h2>
|
Create Account
|
||||||
<p class="mt-2 text-center text-sm text-gray-600">
|
</h1>
|
||||||
Complete the form below to create a new user
|
</div>
|
||||||
</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
</div>
|
Complete the form below to create a new user account
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Registration Card -->
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
<div class="mt-4">
|
<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="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
<div class="flex items-center">
|
||||||
<span class="block sm:inline">{ errorMessage }</span>
|
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||||
|
<span>{ errorMessage }</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
x-data="{
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
email: '',
|
<div class="flex items-center space-x-3">
|
||||||
password: '',
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
confirmPassword: '',
|
<i class="fas fa-user-plus text-blue-600 dark:text-blue-300"></i>
|
||||||
loading: false,
|
</div>
|
||||||
validate() {
|
<div>
|
||||||
return this.email &&
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
this.password &&
|
Registration
|
||||||
this.confirmPassword &&
|
</h3>
|
||||||
this.password === this.confirmPassword;
|
<p class="text-sm text-gray-500 dark:text-gray-400">Create your account credentials</p>
|
||||||
}
|
</div>
|
||||||
}"
|
|
||||||
@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>
|
</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>
|
<div>
|
||||||
<button
|
<h4 class="text-sm font-medium mb-1">Account Information</h4>
|
||||||
type="submit"
|
<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>
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package toast
|
||||||
|
|
||||||
|
templ Container() {
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package toast
|
||||||
|
|
||||||
|
templ ShowToastJS() {
|
||||||
|
<script>
|
||||||
|
// Notification system
|
||||||
|
function showToast(message, type) {
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
if (!toastContainer) {
|
||||||
|
console.error("Toast container not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 { // Default to info
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create icon div
|
||||||
|
const iconDiv = document.createElement('div');
|
||||||
|
iconDiv.className = `inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}`;
|
||||||
|
iconDiv.innerHTML = type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>';
|
||||||
|
|
||||||
|
// Create message div and set text content safely
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = 'ml-3 text-sm font-normal';
|
||||||
|
messageDiv.textContent = message; // Use textContent for safety
|
||||||
|
|
||||||
|
// Create close button
|
||||||
|
const closeButton = document.createElement('button'); // Keep this declaration
|
||||||
|
closeButton.type = 'button';
|
||||||
|
closeButton.className = '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';
|
||||||
|
closeButton.setAttribute('data-dismiss-target', `#${toast.id}`);
|
||||||
|
closeButton.setAttribute('aria-label', 'Close');
|
||||||
|
closeButton.innerHTML = `
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Append elements to the toast
|
||||||
|
toast.appendChild(iconDiv);
|
||||||
|
toast.appendChild(messageDiv);
|
||||||
|
toast.appendChild(closeButton);
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to the close button we created earlier
|
||||||
|
closeButton.addEventListener('click', function() { // Use the existing closeButton variable
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
templ TestResult(success bool, message string) {
|
||||||
|
if success {
|
||||||
|
<div class="text-green-600 dark:text-green-400 flex items-center">
|
||||||
|
<i class="fas fa-check-circle mr-2"></i>
|
||||||
|
<span>{ message }</span>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="text-red-600 dark:text-red-400 flex items-center">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
|
<span>{ message }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,167 +4,197 @@ import "context"
|
|||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
type TwoFactorBackupCodesData struct {
|
type TwoFactorBackupCodesData struct {
|
||||||
BackupCodes []string
|
BackupCodes []string
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
SuccessMessage string
|
SuccessMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
|
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
|
||||||
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
|
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
|
||||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
<!-- Page Header -->
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div class="text-center mb-6">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">2FA Backup Codes</h2>
|
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">These codes can be used to login if you lose access to your authenticator app</p>
|
Two-Factor Authentication Backup Codes
|
||||||
</div>
|
</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 != "" {
|
if data.ErrorMessage != "" {
|
||||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
<div class="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">
|
<div class="flex items-center">
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
<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>
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
if data.SuccessMessage != "" {
|
<div class="p-6">
|
||||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
<!-- Security Alert -->
|
||||||
// Show important messages with a different style and icon
|
<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="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
<div class="flex items-start">
|
||||||
<div class="flex items-center">
|
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
<div>
|
||||||
<span class="block sm:inline font-bold">{ data.SuccessMessage }</span>
|
<h4 class="text-sm font-medium mb-1">Security information</h4>
|
||||||
</div>
|
<p class="font-semibold mb-2">These codes are your backup method to access your account.</p>
|
||||||
</div>
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
} else {
|
<li>Each code can only be used once.</li>
|
||||||
<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">
|
<li>Store these codes in a secure password manager.</li>
|
||||||
<div class="flex items-center">
|
<li>These codes allow access to your account - keep them safe!</li>
|
||||||
<i class="fas fa-info-circle mr-2"></i>
|
<li>For security, you can only view codes immediately after generating them.</li>
|
||||||
<span class="block sm:inline">{ data.SuccessMessage }</span>
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="space-y-8">
|
|
||||||
if len(data.BackupCodes) > 0 {
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Your Backup Codes</h3>
|
|
||||||
|
|
||||||
<div class="p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 rounded-lg mb-6">
|
|
||||||
<div class="flex items-start">
|
|
||||||
<div class="flex-shrink-0 mt-0.5">
|
|
||||||
<i class="fas fa-shield-alt text-amber-600 dark:text-amber-400 text-lg"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">Security information</h4>
|
|
||||||
<div class="mt-1 text-sm text-amber-700 dark:text-amber-400 space-y-2">
|
|
||||||
<p><strong>These codes are your backup method to access your account.</strong></p>
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<li>Each code can only be used once.</li>
|
|
||||||
<li>Store these codes in a secure password manager.</li>
|
|
||||||
<li>These codes allow access to your account - keep them safe!</li>
|
|
||||||
<li>For security, you can only view codes immediately after generating them.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
|
||||||
for _, code := range data.BackupCodes {
|
|
||||||
if strings.Contains(code, "REDACTED") {
|
|
||||||
<div class="p-2 bg-secondary-200 dark:bg-secondary-700 rounded font-mono text-sm text-center text-secondary-500 dark:text-secondary-400">
|
|
||||||
{ code }
|
|
||||||
</div>
|
|
||||||
} else {
|
|
||||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center font-bold">
|
|
||||||
{ code }
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center space-x-4 mt-6">
|
|
||||||
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
|
|
||||||
<button
|
|
||||||
class="btn-secondary"
|
|
||||||
onclick="downloadBackupCodes(this)">
|
|
||||||
<i class="fas fa-download mr-2"></i>
|
|
||||||
Download Backup Codes
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
|
|
||||||
<button type="submit" class="btn-primary">
|
|
||||||
<i class="fas fa-sync-alt mr-2"></i>
|
|
||||||
Generate New Codes
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
function downloadBackupCodes(button) {
|
|
||||||
// Get backup codes from the displayed elements that don't contain "REDACTED"
|
|
||||||
const codes = Array.from(
|
|
||||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
|
||||||
).map(el => el.textContent.trim());
|
|
||||||
|
|
||||||
// Create content for the file
|
|
||||||
const content =
|
|
||||||
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
|
|
||||||
"=====================================\n\n" +
|
|
||||||
codes.join("\n") +
|
|
||||||
"\n\n" +
|
|
||||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
|
||||||
"SECURITY WARNINGS:\n" +
|
|
||||||
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
|
||||||
"* Each code can only be used once.\n" +
|
|
||||||
"* Keep these codes in a secure location like a password manager.\n" +
|
|
||||||
"* Treat these codes with the same security as your password.";
|
|
||||||
|
|
||||||
// Create blob and download link
|
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = '2fa-backup-codes-secure.txt';
|
|
||||||
|
|
||||||
// Trigger download
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
} else {
|
|
||||||
<div class="text-center py-8">
|
<!-- Backup Codes Grid -->
|
||||||
<div class="rounded-full bg-secondary-100 dark:bg-secondary-700 p-4 mx-auto w-16 h-16 flex items-center justify-center mb-4">
|
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||||
<i class="fas fa-key text-secondary-500 dark:text-secondary-400 text-2xl"></i>
|
for _, code := range data.BackupCodes {
|
||||||
</div>
|
if strings.Contains(code, "REDACTED") {
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">No Backup Codes Available</h3>
|
<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">
|
||||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">You don't have any backup codes. Generate new ones for account recovery.</p>
|
{ code }
|
||||||
<form method="POST" action="/profile/2fa/regenerate-codes">
|
</div>
|
||||||
<button type="submit" class="btn-primary">
|
} 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>
|
<i class="fas fa-sync-alt mr-2"></i>
|
||||||
Generate Backup Codes
|
Generate New Codes
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<a href="/profile" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
|
||||||
<i class="fas fa-arrow-left mr-2"></i>
|
|
||||||
Back to Profile
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,76 +8,117 @@ type BackupCodeVerifyData struct {
|
|||||||
|
|
||||||
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
|
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
|
||||||
@LayoutWithContext("Backup Code Verification", ctx) {
|
@LayoutWithContext("Backup Code Verification", ctx) {
|
||||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
<!-- Page Header -->
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div class="text-center mb-8">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100 dark:bg-amber-900 mb-4">
|
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
<i class="fas fa-key text-amber-600 dark:text-amber-400 text-3xl"></i>
|
Backup Code Verification
|
||||||
</div>
|
</h1>
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Backup Code Verification</h2>
|
</div>
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter one of your backup codes</p>
|
<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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
if data.ErrorMessage != "" {
|
<!-- Verification Card -->
|
||||||
<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="max-w-md mx-auto">
|
||||||
<div class="flex items-center">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
<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>
|
||||||
}
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="/login/verify"
|
action="/login/verify"
|
||||||
class="space-y-6"
|
class="space-y-4"
|
||||||
x-data="{ code: '', loading: false }"
|
x-data="{ code: '', loading: false }"
|
||||||
@submit="loading = true">
|
@submit="loading = true">
|
||||||
<div>
|
<div>
|
||||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
Backup Code
|
Backup Code
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
</div>
|
||||||
<input
|
<p class="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-start">
|
||||||
type="text"
|
<i class="fas fa-exclamation-triangle mr-1.5 mt-0.5 flex-shrink-0"></i>
|
||||||
id="code"
|
<span>Remember that each backup code can only be used once!</span>
|
||||||
name="code"
|
</p>
|
||||||
x-model="code"
|
|
||||||
class="form-input pl-10 w-full"
|
|
||||||
placeholder="Enter backup code"
|
|
||||||
required
|
|
||||||
autocomplete="one-time-code"/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
<button
|
||||||
Remember that each backup code can only be used once!
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<!-- Help Notice -->
|
||||||
type="submit"
|
<div class="mt-6 max-w-md mx-auto">
|
||||||
class="btn-primary w-full"
|
<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">
|
||||||
x-bind:disabled="!code.trim() || loading">
|
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
<span x-show="!loading">Verify</span>
|
<div>
|
||||||
<span x-show="loading" class="flex items-center justify-center">
|
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||||
<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">
|
<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>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
</div>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Verifying...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="/login/verify" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
|
||||||
Use authenticator app instead
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+188
-123
@@ -11,137 +11,202 @@ type TwoFactorSetupData struct {
|
|||||||
|
|
||||||
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
|
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
|
||||||
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
|
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
|
||||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
<!-- Page Header -->
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div class="text-center mb-8">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Set Up Two-Factor Authentication</h2>
|
<i class="fas fa-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enhance your account security with 2FA</p>
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
if data.ErrorMessage != "" {
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-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 mb-6" role="alert">
|
<!-- QR Code Section -->
|
||||||
<div class="flex items-center">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-qrcode text-blue-600 dark:text-blue-300"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">1. Scan QR Code</h3>
|
|
||||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
|
||||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<img src={ data.QRCodeURL } alt="QR Code" class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-2 bg-white"/>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
Can't scan the QR code? Use this code instead:
|
|
||||||
</p>
|
|
||||||
<code class="block mt-2 p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm">
|
|
||||||
{ data.Secret }
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">2. Verify Setup</h3>
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="/profile/2fa/verify"
|
|
||||||
class="space-y-4"
|
|
||||||
x-data="{ code: '', loading: false }"
|
|
||||||
@submit="loading = true">
|
|
||||||
<div>
|
|
||||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
|
||||||
Enter the 6-digit code from your authenticator app
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="code"
|
|
||||||
name="code"
|
|
||||||
x-model="code"
|
|
||||||
class="form-input block w-full"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="6"
|
|
||||||
required/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary w-full"
|
|
||||||
x-bind:disabled="code.length !== 6 || loading">
|
|
||||||
<span x-show="!loading">Verify and Enable 2FA</span>
|
|
||||||
<span x-show="loading" class="flex items-center justify-center">
|
|
||||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Verifying...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
if len(data.BackupCodes) > 0 {
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">3. Save Backup Codes</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
1. Scan QR Code
|
||||||
Store these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
|
</h3>
|
||||||
</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Scan this QR code with your authenticator app</p>
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
</div>
|
||||||
for _, code := range data.BackupCodes {
|
</div>
|
||||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center">
|
</div>
|
||||||
{ code }
|
<div class="p-6 flex flex-col items-center">
|
||||||
</div>
|
<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>
|
||||||
<div class="text-center">
|
<div class="w-full text-center mb-2">
|
||||||
<button
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
class="btn-secondary"
|
Can't scan the QR code? Use this code instead:
|
||||||
onclick="downloadBackupCodes(this)">
|
</p>
|
||||||
<i class="fas fa-download mr-2"></i>
|
<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">
|
||||||
Download Backup Codes
|
{ data.Secret }
|
||||||
</button>
|
</div>
|
||||||
<script>
|
</div>
|
||||||
function downloadBackupCodes(button) {
|
</div>
|
||||||
// Get backup codes from the displayed elements
|
</div>
|
||||||
const codes = Array.from(
|
|
||||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
|
||||||
).map(el => el.textContent.trim());
|
|
||||||
|
|
||||||
// Create content for the file
|
<!-- Verification Section -->
|
||||||
const content =
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
"2FA Backup Codes - Keep these safe!\n" +
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
"=====================================\n\n" +
|
<div class="flex items-center space-x-3">
|
||||||
codes.join("\n") +
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
"\n\n" +
|
<i class="fas fa-check-circle text-blue-600 dark:text-blue-300"></i>
|
||||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
</div>
|
||||||
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
<div>
|
||||||
"Each code can only be used once. Keep these codes safe and secure.";
|
<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>
|
||||||
|
|
||||||
// Create blob and download link
|
if len(data.BackupCodes) > 0 {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
const url = window.URL.createObjectURL(blob);
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
const a = document.createElement('a');
|
<div class="flex items-center space-x-3">
|
||||||
a.href = url;
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
a.download = '2fa-backup-codes.txt';
|
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
// Trigger download
|
<div>
|
||||||
document.body.appendChild(a);
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
a.click();
|
3. Save Backup Codes
|
||||||
|
</h3>
|
||||||
// Cleanup
|
<p class="text-sm text-gray-500 dark:text-gray-400">Store these backup codes in a safe place</p>
|
||||||
window.URL.revokeObjectURL(url);
|
</div>
|
||||||
document.body.removeChild(a);
|
</div>
|
||||||
}
|
</div>
|
||||||
</script>
|
<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>
|
||||||
}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,81 +8,114 @@ type TwoFactorVerifyData struct {
|
|||||||
|
|
||||||
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
|
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
|
||||||
@LayoutWithContext("Two-Factor Authentication", ctx) {
|
@LayoutWithContext("Two-Factor Authentication", ctx) {
|
||||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
<!-- Page Header -->
|
||||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
<div class="mb-6 flex flex-col items-center justify-center text-center">
|
||||||
<div class="text-center mb-8">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
<i class="fas fa-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
<i class="fas fa-shield-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
|
Two-Factor Authentication
|
||||||
</div>
|
</h1>
|
||||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Two-Factor Authentication</h2>
|
</div>
|
||||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter the code from your authenticator app</p>
|
<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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
if data.ErrorMessage != "" {
|
<!-- Verification Card -->
|
||||||
<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="max-w-md mx-auto">
|
||||||
<div class="flex items-center">
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
<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>
|
||||||
}
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="/login/verify"
|
action="/login/verify"
|
||||||
class="space-y-6"
|
class="space-y-4"
|
||||||
x-data="{ code: '', loading: false }"
|
x-data="{ code: '', loading: false }"
|
||||||
@submit="loading = true">
|
@submit="loading = true">
|
||||||
<div>
|
<div>
|
||||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
Authentication Code
|
Authentication Code
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
<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>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="code"
|
|
||||||
name="code"
|
|
||||||
x-model="code"
|
|
||||||
class="form-input pl-10 w-full"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="6"
|
|
||||||
placeholder="Enter 6-digit code"
|
|
||||||
required/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<!-- Help Notice -->
|
||||||
type="submit"
|
<div class="mt-6 max-w-md mx-auto">
|
||||||
class="btn-primary w-full"
|
<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">
|
||||||
x-bind:disabled="code.length !== 6 || loading">
|
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
<span x-show="!loading">Verify</span>
|
<div>
|
||||||
<span x-show="loading" class="flex items-center justify-center">
|
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||||
<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">
|
<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>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Verifying...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="/login/backup-code" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
|
||||||
Use a backup code instead
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
Lost your device?
|
|
||||||
<br/>
|
|
||||||
You can use one of your backup codes instead of the 6-digit code.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+144
-169
@@ -1,9 +1,9 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
"strconv"
|
"strconv"
|
||||||
"context"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UsersData struct {
|
type UsersData struct {
|
||||||
@@ -12,88 +12,98 @@ type UsersData struct {
|
|||||||
|
|
||||||
templ Users(ctx context.Context, data UsersData) {
|
templ Users(ctx context.Context, data UsersData) {
|
||||||
@LayoutWithContext("User Management", ctx) {
|
@LayoutWithContext("User Management", ctx) {
|
||||||
<div class="py-6">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<i class="fas fa-users w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
<i class="fas fa-users mr-2 text-primary-600 dark:text-primary-400"></i>
|
User Management
|
||||||
User Management
|
</h1>
|
||||||
</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">
|
||||||
<a href="/admin/users/new" class="btn-primary">
|
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
|
||||||
<i class="fas fa-user-plus mr-2"></i>
|
Add User
|
||||||
Add User
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
if len(data.Users) == 0 {
|
if len(data.Users) == 0 {
|
||||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
<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">
|
||||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
<i class="fas fa-exclamation-triangle flex-shrink-0 inline w-5 h-5 me-3"></i>
|
||||||
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<span class="sr-only">Info</span>
|
||||||
</div>
|
<div>
|
||||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
|
<span class="font-medium">No users found!</span> Click the "Add User" button to create your first user.
|
||||||
<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>
|
</div>
|
||||||
} else {
|
</div>
|
||||||
<div class="card overflow-hidden">
|
} else {
|
||||||
<table class="min-w-full 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">
|
||||||
<thead class="bg-secondary-50 dark:bg-secondary-800">
|
<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>
|
<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">
|
<th scope="col" class="px-6 py-3">
|
||||||
<i class="fas fa-envelope mr-1"></i> Email
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-envelope w-4 h-4 mr-1"></i>
|
||||||
|
Email
|
||||||
|
</div>
|
||||||
</th>
|
</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">
|
<th scope="col" class="px-6 py-3">
|
||||||
<i class="fas fa-user-tag mr-1"></i> Role
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-user-tag w-4 h-4 mr-1"></i>
|
||||||
|
Role
|
||||||
|
</div>
|
||||||
</th>
|
</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">
|
<th scope="col" class="px-6 py-3">
|
||||||
<i class="fas fa-calendar-plus mr-1"></i> Created
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calendar-plus w-4 h-4 mr-1"></i>
|
||||||
|
Created
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="relative px-6 py-3">
|
<th scope="col" class="px-6 py-3">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white dark:bg-secondary-750 divide-y divide-secondary-200 dark:divide-secondary-700">
|
<tbody>
|
||||||
if len(data.Users) == 0 {
|
if len(data.Users) == 0 {
|
||||||
<tr>
|
<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-secondary-500 dark:text-secondary-400">
|
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
No users found
|
No users found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} else {
|
} else {
|
||||||
for _, user := range data.Users {
|
for _, user := range data.Users {
|
||||||
<tr class="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
<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 whitespace-nowrap">
|
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||||
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
|
{ user.Email }
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4">
|
||||||
if user.GetIsAdmin() {
|
if user.GetIsAdmin() {
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-300">
|
<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 mr-1"></i> Admin
|
<i class="fas fa-user-shield w-3 h-3 mr-1"></i> Admin
|
||||||
</span>
|
</span>
|
||||||
} else {
|
} 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">
|
<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 mr-1"></i> User
|
<i class="fas fa-user w-3 h-3 mr-1"></i> User
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</td>
|
</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") }
|
{ user.CreatedAt.Format("Jan 02, 2006") }
|
||||||
</td>
|
</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
|
<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-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-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||||
hx-target="body"
|
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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,15 +112,18 @@ templ Users(ctx context.Context, data UsersData) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Help Notice -->
|
<!-- Help Notice -->
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8">
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<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 mr-1 text-primary-500"></i>
|
<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
|
User accounts provide secure access to the GoMFT application with role-based permissions
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,161 +137,123 @@ type UserFormData struct {
|
|||||||
|
|
||||||
templ UserForm(ctx context.Context, data UserFormData) {
|
templ UserForm(ctx context.Context, data UserFormData) {
|
||||||
@LayoutWithContext("Add User", ctx) {
|
@LayoutWithContext("Add User", ctx) {
|
||||||
<div class="py-6">
|
<div class="p-4 md:p-6 2xl:p-10">
|
||||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<i class="fas fa-user-plus mr-2 text-primary-600 dark:text-primary-400"></i>
|
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
|
||||||
Add New User
|
Add New User
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card overflow-hidden">
|
if data.ErrorMessage != "" {
|
||||||
<div class="p-6">
|
<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">
|
||||||
if data.ErrorMessage != "" {
|
<div class="flex items-center">
|
||||||
<div class="mb-6">
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
<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">
|
<span>{ data.ErrorMessage }</span>
|
||||||
<div class="flex items-center">
|
</div>
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
</div>
|
||||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
}
|
||||||
</div>
|
|
||||||
</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">
|
||||||
<form
|
<form
|
||||||
class="space-y-6"
|
class="space-y-6"
|
||||||
hx-post="/admin/users"
|
action="/admin/users/new"
|
||||||
hx-target="body"
|
method="POST"
|
||||||
x-data="{
|
x-data="{
|
||||||
email: '',
|
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
loading: false,
|
passwordsMatch() {
|
||||||
validate() {
|
return this.password === this.confirmPassword || this.confirmPassword === '';
|
||||||
return this.email &&
|
|
||||||
this.password &&
|
|
||||||
this.confirmPassword &&
|
|
||||||
this.password === this.confirmPassword;
|
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@htmx:before-request="loading = true"
|
>
|
||||||
@htmx:after-request="loading = false">
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">User Information</h3>
|
<h3 class="text-lg font-medium leading-none text-gray-900 dark:text-white">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>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Create a new user account with appropriate permissions.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email</label>
|
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label>
|
||||||
<div class="relative">
|
<input
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
type="email"
|
||||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
name="email"
|
||||||
</div>
|
id="email"
|
||||||
<input
|
required
|
||||||
type="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"
|
||||||
name="email"
|
placeholder="user@example.com"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
<div class="relative">
|
<input
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
type="password"
|
||||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
name="password"
|
||||||
</div>
|
id="password"
|
||||||
<input
|
required
|
||||||
type="password"
|
x-model="password"
|
||||||
name="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"
|
||||||
id="password"
|
placeholder="••••••••"
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="confirm_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm Password</label>
|
<label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label>
|
||||||
<div class="relative">
|
<input
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
type="password"
|
||||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
name="password_confirm"
|
||||||
</div>
|
id="password_confirm"
|
||||||
<input
|
required
|
||||||
type="password"
|
x-model="confirmPassword"
|
||||||
name="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"
|
||||||
id="confirm_password"
|
placeholder="••••••••"
|
||||||
x-model="confirmPassword"
|
/>
|
||||||
required
|
<p class="mt-1 text-sm text-red-600 dark:text-red-500" x-show="!passwordsMatch() && 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"
|
Passwords do not match
|
||||||
placeholder="••••••••" />
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<div class="relative flex items-start">
|
<div class="flex items-start">
|
||||||
<div class="flex items-center h-5">
|
<div class="flex items-center h-5">
|
||||||
<input
|
<input
|
||||||
id="is_admin"
|
id="is_admin"
|
||||||
name="is_admin"
|
name="is_admin"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
x-model="isAdmin"
|
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>
|
||||||
<div class="ml-3 text-sm">
|
<div class="ml-3 text-sm">
|
||||||
<label for="is_admin" class="font-medium text-secondary-700 dark:text-secondary-300">Administrator</label>
|
<label for="is_admin" class="font-medium text-gray-900 dark:text-white">Administrator</label>
|
||||||
<p class="text-secondary-500 dark:text-secondary-400">Grant administrative privileges to this user</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-5">
|
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex justify-end space-x-3">
|
<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">
|
||||||
<a href="/admin/users" class="btn-secondary">
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
<i class="fas fa-times mr-2"></i>
|
Cancel
|
||||||
Cancel
|
</a>
|
||||||
</a>
|
<button
|
||||||
<button
|
type="submit"
|
||||||
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"
|
||||||
class="btn-primary"
|
x-bind:disabled="!passwordsMatch() && confirmPassword !== ''"
|
||||||
x-bind:disabled="!validate() || loading">
|
>
|
||||||
<span x-show="!loading" class="flex items-center">
|
<i class="fas fa-save mr-2"></i>
|
||||||
<i class="fas fa-user-plus mr-2"></i>
|
Create User
|
||||||
Create User
|
</button>
|
||||||
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+88
-17
@@ -16,15 +16,59 @@ if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
|||||||
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
|
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
|
||||||
|
|
||||||
# Update group ID first
|
# Update group ID first
|
||||||
if [ "$(getent group ${USERNAME} | cut -d: -f3)" != "${PGID}" ]; then
|
CURRENT_GID=$(getent group ${USERNAME} | cut -d: -f3)
|
||||||
echo "Updating GID to ${PGID}..."
|
CURRENT_UID=$(id -u ${USERNAME})
|
||||||
groupmod -g ${PGID} ${USERNAME} || echo "⚠️ Failed to change GID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update user ID
|
if [ "${CURRENT_GID}" != "${PGID}" ] || [ "${CURRENT_UID}" != "${PUID}" ]; then
|
||||||
if [ "$(id -u ${USERNAME})" != "${PUID}" ]; then
|
echo "Attempting to update UID/GID to ${PUID}:${PGID} using delete/recreate..."
|
||||||
echo "Updating UID to ${PUID}..."
|
|
||||||
usermod -u ${PUID} ${USERNAME} || echo "⚠️ Failed to change UID"
|
# Delete existing user and group, ignoring errors
|
||||||
|
deluser ${USERNAME} > /dev/null 2>&1 || true
|
||||||
|
delgroup ${USERNAME} > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Check if a group with the target GID already exists
|
||||||
|
EXISTING_GROUP=$(getent group ${PGID} | cut -d: -f1 || echo "")
|
||||||
|
|
||||||
|
if [ -n "${EXISTING_GROUP}" ]; then
|
||||||
|
echo "Group with GID ${PGID} already exists as '${EXISTING_GROUP}', will use this group"
|
||||||
|
# Set USERNAME_GROUP to the existing group name
|
||||||
|
USERNAME_GROUP="${EXISTING_GROUP}"
|
||||||
|
else
|
||||||
|
# Add group with the specified GID
|
||||||
|
echo "Adding group ${USERNAME} with GID ${PGID}"
|
||||||
|
if ! addgroup -g ${PGID} ${USERNAME}; then
|
||||||
|
echo "⚠️ Failed to add group ${USERNAME} with GID ${PGID}."
|
||||||
|
# Exiting because user creation will likely fail
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
USERNAME_GROUP="${USERNAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add user with the specified UID and GID
|
||||||
|
# Use -G for primary group with adduser in BusyBox
|
||||||
|
# Use -h /app for home directory (consistent with expectations)
|
||||||
|
# Use -s /bin/sh for shell
|
||||||
|
# Use -D for no password (system user)
|
||||||
|
echo "Adding user ${USERNAME} with UID ${PUID} and group ${USERNAME_GROUP}"
|
||||||
|
if ! adduser -u ${PUID} -G ${USERNAME_GROUP} -h /app -s /bin/sh -D ${USERNAME}; then
|
||||||
|
echo "⚠️ Failed to add user ${USERNAME} with UID ${PUID} and group ${USERNAME_GROUP}."
|
||||||
|
# Exiting because the application cannot run as the correct user
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify the change
|
||||||
|
FINAL_UID=$(id -u ${USERNAME} 2>/dev/null || echo "error")
|
||||||
|
FINAL_GID=$(id -g ${USERNAME} 2>/dev/null || echo "error")
|
||||||
|
|
||||||
|
if [ "${FINAL_UID}" = "${PUID}" ] && [ "${FINAL_GID}" = "${PGID}" ]; then
|
||||||
|
echo "✅ Successfully updated UID/GID to ${PUID}:${PGID}"
|
||||||
|
else
|
||||||
|
echo "⚠️ Verification failed after update. Target: ${PUID}:${PGID}, Actual: ${FINAL_UID}:${FINAL_GID}"
|
||||||
|
# Exiting because the UID/GID is not correct
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "UID/GID ${PUID}:${PGID} already set."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo "Non-Alpine system, using standard user management..."
|
echo "Non-Alpine system, using standard user management..."
|
||||||
@@ -45,13 +89,40 @@ if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
|||||||
groupdel ${USERNAME} 2>/dev/null || true
|
groupdel ${USERNAME} 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Recreate group and user in the correct order
|
# Check if a group with the target GID already exists
|
||||||
echo "Creating group ${USERNAME} with GID ${PGID}"
|
EXISTING_GROUP=$(getent group ${PGID} | cut -d: -f1 || echo "")
|
||||||
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "Creating user ${USERNAME} with UID ${PUID}"
|
if [ -n "${EXISTING_GROUP}" ]; then
|
||||||
useradd -u ${PUID} -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null ||
|
echo "Group with GID ${PGID} already exists as '${EXISTING_GROUP}', will use this group"
|
||||||
useradd -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null || true
|
# Set USERNAME_GROUP to the existing group name
|
||||||
|
USERNAME_GROUP="${EXISTING_GROUP}"
|
||||||
|
else
|
||||||
|
# Recreate group with the specified GID
|
||||||
|
echo "Creating group ${USERNAME} with GID ${PGID}"
|
||||||
|
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
|
||||||
|
USERNAME_GROUP="${USERNAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if a user with the target UID already exists
|
||||||
|
EXISTING_USER=$(getent passwd ${PUID} | cut -d: -f1 || echo "")
|
||||||
|
|
||||||
|
if [ -n "${EXISTING_USER}" ] && [ "${EXISTING_USER}" != "${USERNAME}" ]; then
|
||||||
|
echo "⚠️ Warning: User with UID ${PUID} already exists as '${EXISTING_USER}'. Using a different username may cause issues."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating user ${USERNAME} with UID ${PUID} and group ${USERNAME_GROUP}"
|
||||||
|
useradd -u ${PUID} -g ${USERNAME_GROUP} -s /bin/sh ${USERNAME} 2>/dev/null ||
|
||||||
|
useradd -g ${USERNAME_GROUP} -s /bin/sh ${USERNAME} 2>/dev/null || true
|
||||||
|
|
||||||
|
# Verify the change
|
||||||
|
FINAL_UID=$(id -u ${USERNAME} 2>/dev/null || echo "error")
|
||||||
|
FINAL_GID=$(id -g ${USERNAME} 2>/dev/null || echo "error")
|
||||||
|
|
||||||
|
if [ "${FINAL_UID}" = "${PUID}" ] && [ "${FINAL_GID}" = "${PGID}" ]; then
|
||||||
|
echo "✅ Successfully updated UID/GID to ${PUID}:${PGID}"
|
||||||
|
else
|
||||||
|
echo "⚠️ Warning: Verification failed. Target: ${PUID}:${PGID}, Actual: ${FINAL_UID}:${FINAL_GID}"
|
||||||
|
fi
|
||||||
} || {
|
} || {
|
||||||
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
|
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
|
||||||
}
|
}
|
||||||
@@ -59,17 +130,17 @@ if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
|||||||
|
|
||||||
# Fix ownership of app directories
|
# Fix ownership of app directories
|
||||||
echo "Setting ownership of app directories"
|
echo "Setting ownership of app directories"
|
||||||
chown -R ${USERNAME}:${USERNAME} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
|
chown -R ${USERNAME}:${USERNAME_GROUP:-${USERNAME}} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
|
||||||
|
|
||||||
# Ensure .env file exists and has correct permissions
|
# Ensure .env file exists and has correct permissions
|
||||||
if [ -f /app/.env ]; then
|
if [ -f /app/.env ]; then
|
||||||
echo "Found .env file, setting permissions..."
|
echo "Found .env file, setting permissions..."
|
||||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
chown ${USERNAME}:${USERNAME_GROUP:-${USERNAME}} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||||
else
|
else
|
||||||
echo "No .env file found, creating empty one..."
|
echo "No .env file found, creating empty one..."
|
||||||
touch /app/.env
|
touch /app/.env
|
||||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
chown ${USERNAME}:${USERNAME_GROUP:-${USERNAME}} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
github.com/pquerna/otp v1.4.0
|
github.com/pquerna/otp v1.4.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.35.0
|
golang.org/x/crypto v0.36.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
@@ -47,13 +47,12 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.14.0 // indirect
|
golang.org/x/arch v0.14.0 // indirect
|
||||||
golang.org/x/net v0.35.0 // indirect
|
golang.org/x/net v0.37.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.5 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user