mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-15 19:10:55 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd7426a01f | ||
|
|
fde60b7d68 | ||
|
|
297e5ca92b | ||
|
|
405b2e605e | ||
|
|
1f94641dee | ||
|
|
9249b84ddd | ||
|
|
a7cd021ff2 | ||
|
|
8424e97cf8 | ||
|
|
f128eff8de | ||
|
|
db1a0ad5f5 | ||
|
|
8e1321d251 | ||
|
|
ef0829aaa4 | ||
|
|
4d393e17ab | ||
|
|
d35f7bfe53 | ||
|
|
b91b0e8796 | ||
|
|
233f1779d8 | ||
|
|
0cebfcf1f5 | ||
|
|
fb3f834c36 | ||
|
|
30d0725331 | ||
|
|
3140d9e143 | ||
|
|
db6259f8a3 | ||
|
|
49a586db53 | ||
|
|
aa2149e2cf | ||
|
|
8dac5b390c | ||
|
|
faef3c2df9 | ||
|
|
874c7ad69e | ||
|
|
7de4f57fc3 | ||
|
|
9a49d87777 | ||
|
|
73362793ad | ||
|
|
d6bd471eb0 | ||
|
|
1a1df435de | ||
|
|
e031e67b3c | ||
|
|
402072540f | ||
|
|
be19a5efde |
@@ -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:
|
||||||
@@ -66,7 +86,7 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
|
|
||||||
# Define common ldflags with version information
|
# Define common ldflags with version information
|
||||||
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT"
|
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
|
||||||
|
|
||||||
# Linux builds
|
# Linux builds
|
||||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
|
||||||
@@ -101,4 +121,4 @@ jobs:
|
|||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
draft: false
|
draft: false
|
||||||
# The following line is not needed as we set permissions at workflow level
|
# The following line is not needed as we set permissions at workflow level
|
||||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
+3
-1
@@ -79,6 +79,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
|
||||||
|
|||||||
@@ -52,6 +52,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 +86,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
|
||||||
@@ -355,7 +372,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 +414,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 +424,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
|
||||||
|
|
||||||
@@ -479,13 +568,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.Enabled {
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
name="enabled"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
name="enabled"
|
||||||
|
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<label for="enabled" class="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Whether this authentication provider is active and available for login</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Optional description of this provider"
|
||||||
|
>
|
||||||
|
if provider != nil {
|
||||||
|
{ provider.Description }
|
||||||
|
}
|
||||||
|
</textarea>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Additional information about this authentication provider</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Icon URL -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Provider Icon</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="icon_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="icon_url"
|
||||||
|
name="icon_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://example.com/icon.svg"
|
||||||
|
value={ provider.IconURL }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the provider's icon image (SVG recommended)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon Preview</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div id="icon-preview" class="border border-gray-300 dark:border-gray-700 rounded-lg p-4 flex items-center justify-center w-20 h-20 bg-white dark:bg-gray-800">
|
||||||
|
if provider != nil && provider.IconURL != "" {
|
||||||
|
<img src={ provider.IconURL } class="max-w-full max-h-full" id="preview-img" />
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-image text-gray-400 text-xl" id="preview-placeholder"></i>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Preview of the icon that will be displayed on the login button.</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no URL is provided, a default icon will be used based on the provider type.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Connection Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Provider URL -->
|
||||||
|
<div>
|
||||||
|
<label for="provider_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider URL <span class="text-red-600">*</span></label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="provider_url"
|
||||||
|
name="provider_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://authentik.example.com"
|
||||||
|
required
|
||||||
|
value={ getValue(provider, "provider_url") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The base URL of the authentication provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client ID -->
|
||||||
|
<div>
|
||||||
|
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID <span class="text-red-600">*</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="client_id"
|
||||||
|
name="client_id"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
required
|
||||||
|
value={ getValue(provider, "client_id") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client identifier assigned by the authentication provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Secret -->
|
||||||
|
<div>
|
||||||
|
<label for="client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Client Secret
|
||||||
|
if !isNew {
|
||||||
|
<span class="text-gray-500 text-xs font-normal ml-2">(leave empty to keep current)</span>
|
||||||
|
} else {
|
||||||
|
<span class="text-red-600">*</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
if isNew {
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="client_secret"
|
||||||
|
name="client_secret"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="client_secret"
|
||||||
|
name="client_secret"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client secret for authentication with the provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Redirect URL -->
|
||||||
|
<div>
|
||||||
|
<label for="redirect_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Redirect URL <span class="text-red-600">*</span></label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="redirect_url"
|
||||||
|
name="redirect_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://your-app.example.com/auth/callback"
|
||||||
|
required
|
||||||
|
value={ getRedirectURL(provider) }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The callback URL that will handle the authentication response</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scopes -->
|
||||||
|
<div>
|
||||||
|
<label for="scopes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="scopes"
|
||||||
|
name="scopes"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="openid profile email"
|
||||||
|
value={ getValue(provider, "scopes") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Space-separated list of scopes to request from the provider</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="authentikSection">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Authentik Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Authentik Tenant ID -->
|
||||||
|
<div>
|
||||||
|
<label for="authentik_tenant" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tenant ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="authentik_tenant"
|
||||||
|
name="authentik_tenant"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="default"
|
||||||
|
value={ getConfigValue(provider, "tenant_id") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Authentik tenant ID (optional, defaults to 'default')</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="oidcSection">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">OpenID Connect Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- OIDC Discovery URL -->
|
||||||
|
<div>
|
||||||
|
<label for="oidc_discovery_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Discovery URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="oidc_discovery_url"
|
||||||
|
name="oidc_discovery_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://provider/.well-known/openid-configuration"
|
||||||
|
value={ getConfigValue(provider, "discovery_url") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the OIDC discovery document</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="samlSection">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">SAML Settings</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<!-- SAML Metadata URL -->
|
||||||
|
<div>
|
||||||
|
<label for="saml_metadata_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Metadata URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="saml_metadata_url"
|
||||||
|
name="saml_metadata_url"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="https://provider/metadata.xml"
|
||||||
|
value={ getConfigValue(provider, "metadata_url") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the SAML metadata XML</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">User Attribute Mapping</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Username Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_username"
|
||||||
|
name="attr_username"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="preferred_username"
|
||||||
|
value={ getAttributeValue(provider, "username") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the username</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_email"
|
||||||
|
name="attr_email"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="email"
|
||||||
|
value={ getAttributeValue(provider, "email") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the email address</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Name Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Display Name Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_name"
|
||||||
|
name="attr_name"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="name"
|
||||||
|
value={ getAttributeValue(provider, "name") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the display name</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groups Attribute -->
|
||||||
|
<div>
|
||||||
|
<label for="attr_groups" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Groups Attribute</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="attr_groups"
|
||||||
|
name="attr_groups"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="groups"
|
||||||
|
value={ getAttributeValue(provider, "groups") }
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute that contains user groups</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
|
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
if !isNew {
|
||||||
|
<button type="button" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700" onclick="testProviderConnection()">
|
||||||
|
<i class="fas fa-check-circle w-4 h-4 mr-2"></i>
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
if isNew {
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Create Provider
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-save w-4 h-4 mr-2"></i>
|
||||||
|
Save Changes
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValue(provider *db.AuthProvider, field string) string {
|
||||||
|
if provider == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch field {
|
||||||
|
case "name":
|
||||||
|
return provider.Name
|
||||||
|
case "provider_url":
|
||||||
|
return provider.ProviderURL
|
||||||
|
case "client_id":
|
||||||
|
return provider.ClientID
|
||||||
|
case "redirect_url":
|
||||||
|
return provider.RedirectURL
|
||||||
|
case "scopes":
|
||||||
|
return provider.Scopes
|
||||||
|
case "icon_url":
|
||||||
|
return provider.IconURL
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRedirectURL(provider *db.AuthProvider) string {
|
||||||
|
if provider == nil || provider.RedirectURL == "" {
|
||||||
|
return fmt.Sprintf("https://%s/auth/callback", "your-app-domain.com")
|
||||||
|
}
|
||||||
|
return provider.RedirectURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfigValue(provider *db.AuthProvider, key string) string {
|
||||||
|
if provider == nil || provider.Config == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(provider.Config), &config); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := config[key]; ok {
|
||||||
|
if strValue, ok := value.(string); ok {
|
||||||
|
return strValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAttributeValue(provider *db.AuthProvider, key string) string {
|
||||||
|
if provider == nil || provider.AttributeMapping == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapping map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(provider.AttributeMapping), &mapping); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if value, ok := mapping[key]; ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formatTime formats a time.Time value as a human-readable string
|
||||||
|
func formatTime(t time.Time) string {
|
||||||
|
return t.Format("Jan 02, 2006 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
|
||||||
|
@LayoutWithContext("Authentication Providers", ctx) {
|
||||||
|
<!-- Toast container for notifications -->
|
||||||
|
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
<div id="auth-providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-providers-page bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="p-4 pb-8 w-full">
|
||||||
|
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||||
|
Authentication Providers
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Manage external authentication sources like Authentik, OIDC, SAML, etc.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/admin/settings/auth-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
New Provider
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
if len(providers) > 0 {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm text-left rtl:text-right">
|
||||||
|
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3">Name</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Icon</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Type</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Status</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Provider URL</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Last Used</th>
|
||||||
|
<th scope="col" class="px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
for _, provider := range providers {
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
|
{ provider.Name }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
if provider.IconURL != "" {
|
||||||
|
<img src={ provider.IconURL } class="w-6 h-6" alt={ provider.Name + " icon" } />
|
||||||
|
} else {
|
||||||
|
if provider.Type == db.ProviderTypeAuthentik {
|
||||||
|
<img src="/static/img/authentik.svg" class="w-6 h-6" alt="Authentik" />
|
||||||
|
} else if provider.Type == db.ProviderTypeOIDC {
|
||||||
|
<img src="/static/img/oidc.svg" class="w-6 h-6" alt="OIDC" />
|
||||||
|
} else if provider.Type == db.ProviderTypeSAML {
|
||||||
|
<img src="/static/img/saml.svg" class="w-6 h-6" alt="SAML" />
|
||||||
|
} else if provider.Type == db.ProviderTypeOAuth2 {
|
||||||
|
<img src="/static/img/oauth2.svg" class="w-6 h-6" alt="OAuth2" />
|
||||||
|
} else {
|
||||||
|
<i class="fas fa-user-shield text-blue-500 text-lg"></i>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 uppercase">
|
||||||
|
{ string(provider.Type) }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
if provider.Enabled {
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
} else {
|
||||||
|
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 max-w-[200px] truncate">
|
||||||
|
{ provider.ProviderURL }
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
if provider.LastUsed.Valid {
|
||||||
|
{ formatTime(provider.LastUsed.Time) }
|
||||||
|
} else {
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">Never</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href={ templ.SafeURL("/admin/settings/auth-providers/" + fmt.Sprint(provider.ID) + "/edit") }
|
||||||
|
class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700"
|
||||||
|
title="Edit">
|
||||||
|
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||||
|
data-provider-name={ provider.Name }
|
||||||
|
class="delete-provider-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||||
|
title="Delete">
|
||||||
|
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||||
|
onclick="testProvider(this)"
|
||||||
|
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
|
||||||
|
title="Test Connection">
|
||||||
|
<i class="fas fa-check-circle w-3.5 h-3.5 mr-1.5"></i>
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||||
|
<i class="fas fa-user-shield text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Authentication Providers</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
You haven't set up any external authentication providers yet.
|
||||||
|
</p>
|
||||||
|
<a href="/admin/settings/auth-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||||
|
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||||
|
Add First Provider
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-start mb-2">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Make sure to configure callback URLs in your provider's settings.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Test your connections to ensure proper communication with external authentication systems.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Provider Modal using Flowbite style -->
|
||||||
|
<div id="deleteProviderModal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||||
|
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||||
|
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||||
|
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete the provider <strong id="providerNameConfirm"></strong>?</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="confirmDeleteBtn"
|
||||||
|
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeDeleteModal()" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toast notification function
|
||||||
|
function showToast(type, message) {
|
||||||
|
const toastContainer = document.getElementById('toast-container');
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.id = 'toast-' + type + '-' + Date.now();
|
||||||
|
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||||
|
toast.role = 'alert';
|
||||||
|
|
||||||
|
// Set toast content based on type
|
||||||
|
let iconClass, bgColorClass, textColorClass;
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||||
|
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
textColorClass = 'text-green-500 dark:text-green-200';
|
||||||
|
} else if (type === 'error') {
|
||||||
|
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||||
|
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
textColorClass = 'text-red-500 dark:text-red-200';
|
||||||
|
} else {
|
||||||
|
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||||
|
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set inner HTML with appropriate icon and message
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||||
|
${type === 'success'
|
||||||
|
? '<i class="fas fa-check"></i>'
|
||||||
|
: type === 'error'
|
||||||
|
? '<i class="fas fa-exclamation-circle"></i>'
|
||||||
|
: '<i class="fas fa-info-circle"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||||
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast to container
|
||||||
|
toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger animation after a small delay to ensure the DOM has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||||
|
toast.classList.add('translate-y-0', 'opacity-100');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Add event listener to close button
|
||||||
|
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||||
|
closeButton.addEventListener('click', function() {
|
||||||
|
// Animate out before removing
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-remove toast after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('opacity-0', 'translate-y-4');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteProvider(providerId, providerName) {
|
||||||
|
document.getElementById('providerNameConfirm').textContent = providerName;
|
||||||
|
document.getElementById('confirmDeleteBtn').onclick = () => deleteProvider(providerId);
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
document.getElementById('deleteProviderModal').classList.remove('hidden');
|
||||||
|
document.getElementById('deleteProviderModal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteProviderModal').classList.add('hidden');
|
||||||
|
document.getElementById('deleteProviderModal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteProvider(providerId) {
|
||||||
|
fetch(`/admin/settings/auth-providers/${providerId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast('error', data.error);
|
||||||
|
} else {
|
||||||
|
showToast('success', 'Authentication provider deleted successfully');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
closeDeleteModal();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showToast('error', 'Failed to delete authentication provider');
|
||||||
|
closeDeleteModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testProvider(btn) {
|
||||||
|
const providerId = btn.getAttribute('data-provider-id');
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin w-3.5 h-3.5 mr-1.5"></i> Testing...';
|
||||||
|
|
||||||
|
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast('error', data.error);
|
||||||
|
} else if (data.success) {
|
||||||
|
showToast('success', data.message || 'Connection test successful');
|
||||||
|
} else {
|
||||||
|
showToast('error', 'Connection test failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showToast('error', 'Connection test failed');
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set dark background color if in dark mode
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
document.getElementById('auth-providers-container').style.backgroundColor = '#111827';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for theme changes
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
document.getElementById('auth-providers-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up event listeners for delete buttons
|
||||||
|
document.querySelectorAll('.delete-provider-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const providerId = this.getAttribute('data-provider-id');
|
||||||
|
const providerName = this.getAttribute('data-provider-name');
|
||||||
|
confirmDeleteProvider(providerId, providerName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/starfleetcptn/gomft/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getProviderIcon returns the appropriate icon for a provider
|
||||||
|
func getProviderIcon(provider db.AuthProvider) templ.Component {
|
||||||
|
// If provider has a custom icon URL, use it
|
||||||
|
if provider.IconURL != "" {
|
||||||
|
return templ.Raw(fmt.Sprintf(`<img src="%s" class="w-5 h-5" alt="%s icon" />`, provider.IconURL, provider.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise fall back to default icons based on type
|
||||||
|
switch provider.Type {
|
||||||
|
case db.ProviderTypeAuthentik:
|
||||||
|
return templ.Raw(`<img src="/static/img/authentik.svg" class="w-5 h-5" alt="Authentik" />`)
|
||||||
|
case db.ProviderTypeOIDC:
|
||||||
|
return templ.Raw(`<img src="/static/img/oidc.svg" class="w-5 h-5" alt="OIDC" />`)
|
||||||
|
case db.ProviderTypeSAML:
|
||||||
|
return templ.Raw(`<img src="/static/img/saml.svg" class="w-5 h-5" alt="SAML" />`)
|
||||||
|
case db.ProviderTypeOAuth2:
|
||||||
|
return templ.Raw(`<img src="/static/img/oauth2.svg" class="w-5 h-5" alt="OAuth2" />`)
|
||||||
|
default:
|
||||||
|
return templ.Raw(`<i class="fas fa-user-shield text-blue-500"></i>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AuthProviderButtons(providers []db.AuthProvider) {
|
||||||
|
if len(providers) == 0 {
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
No external authentication providers available
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="space-y-2 w-full">
|
||||||
|
for _, provider := range providers {
|
||||||
|
if provider.Enabled {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
|
||||||
|
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
|
||||||
|
@getProviderIcon(provider)
|
||||||
|
</span>
|
||||||
|
<span>{ provider.Name }</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
+318
-165
@@ -7,7 +7,6 @@ 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 {
|
||||||
@@ -47,7 +46,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 := ""
|
||||||
@@ -75,7 +74,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 +83,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 +118,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 +153,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 +164,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,12 +241,17 @@ 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,
|
||||||
sourcePathError: '',
|
sourcePathError: '',
|
||||||
destPathValid: null,
|
destPathValid: null,
|
||||||
destPathError: '',
|
destPathError: '',
|
||||||
|
|
||||||
|
// Command configuration
|
||||||
|
requiresDestination: true,
|
||||||
|
|
||||||
// Methods for path validation
|
// Methods for path validation
|
||||||
checkPath(path, type) {
|
checkPath(path, type) {
|
||||||
@@ -263,6 +271,68 @@ 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)) {
|
||||||
|
// Map command IDs to command names - adjust these based on your actual command IDs
|
||||||
|
case 1: commandName = 'copy'; break;
|
||||||
|
case 2: commandName = 'move'; break;
|
||||||
|
case 3: commandName = 'sync'; break;
|
||||||
|
case 4: commandName = 'ls'; break;
|
||||||
|
case 5: commandName = 'lsd'; break;
|
||||||
|
case 6: commandName = 'lsl'; break;
|
||||||
|
case 7: commandName = 'lsf'; break;
|
||||||
|
case 8: commandName = 'lsjson'; break;
|
||||||
|
case 9: commandName = 'md5sum'; break;
|
||||||
|
case 10: commandName = 'sha1sum'; break;
|
||||||
|
case 11: commandName = 'size'; break;
|
||||||
|
case 12: commandName = 'delete'; break;
|
||||||
|
case 13: commandName = 'purge'; break;
|
||||||
|
case 14: commandName = 'mkdir'; break;
|
||||||
|
case 15: commandName = 'rmdir'; break;
|
||||||
|
case 16: commandName = 'rmdirs'; break;
|
||||||
|
case 17: commandName = 'check'; break;
|
||||||
|
case 18: commandName = 'cleanup'; break;
|
||||||
|
case 19: commandName = 'dedupe'; break;
|
||||||
|
case 20: commandName = 'version'; break;
|
||||||
|
case 21: commandName = 'listremotes'; break;
|
||||||
|
case 22: commandName = 'cryptcheck'; break;
|
||||||
|
case 23: commandName = 'bisync'; break;
|
||||||
|
case 24: commandName = 'copyto'; break;
|
||||||
|
case 25: commandName = 'moveto'; break;
|
||||||
|
default: commandName = 'copy'; // Default to copy
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +345,254 @@ 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';
|
||||||
|
sourcePort = sourcePort || 22;
|
||||||
|
destPort = destPort || 22;
|
||||||
|
// Initialize command requirements
|
||||||
|
updateCommandRequirements();
|
||||||
|
})"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Use the RcloneFlags component from common package -->
|
||||||
|
@common.RcloneFlags()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Configuration Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-upload mr-2 text-blue-500 dark:text-blue-400"></i>Source Configuration
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Source selection -->
|
||||||
|
@common.SourceSelection()
|
||||||
|
|
||||||
|
<!-- Source type specific forms -->
|
||||||
|
<template x-if="sourceType === 'local'">
|
||||||
|
@source.LocalSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'sftp'">
|
||||||
|
@source.SFTPSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'ftp'">
|
||||||
|
@source.FTPSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 's3'">
|
||||||
|
@source.S3SourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'minio'">
|
||||||
|
@source.MinIOSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'smb'">
|
||||||
|
@source.SMBSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'webdav'">
|
||||||
|
@source.WebDAVSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'nextcloud'">
|
||||||
|
@source.NextCloudSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'gdrive'">
|
||||||
|
@source.GoogleDriveSourceForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="sourceType === 'gphotos'">
|
||||||
|
@source.GooglePhotosSourceForm()
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Pattern Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-filter mr-2 text-blue-500 dark:text-blue-400"></i>File Patterns
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- File pattern fields -->
|
||||||
|
@common.FilePatternFields()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destination Configuration Section (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()
|
||||||
|
|
||||||
|
<!-- Destination type specific forms -->
|
||||||
|
<template x-if="destinationType === 'local'">
|
||||||
|
@destination.LocalDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'sftp'">
|
||||||
|
@destination.SFTPDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'ftp'">
|
||||||
|
@destination.FTPDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 's3'">
|
||||||
|
@destination.S3DestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'minio'">
|
||||||
|
@destination.MinIODestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'smb'">
|
||||||
|
@destination.SMBDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'nextcloud'">
|
||||||
|
@destination.NextCloudDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'webdav'">
|
||||||
|
@destination.WebDAVDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'gdrive'">
|
||||||
|
@destination.GoogleDriveDestinationForm()
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="destinationType === 'gphotos'">
|
||||||
|
@destination.GooglePhotosDestinationForm()
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Options Section -->
|
||||||
|
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<i class="fas fa-cogs mr-2 text-blue-500 dark:text-blue-400"></i>Advanced Options
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Archive options -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Archive Settings</h4>
|
||||||
|
@common.ArchiveOptions()
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+289
-122
@@ -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>
|
||||||
<!-- Google Drive Auth Help -->
|
</div>
|
||||||
<div class="mt-4 text-center">
|
</div>
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
|
||||||
<i class="fab fa-google mr-1 text-blue-500 inline-flex items-center"></i>
|
<div class="flex items-start mt-4">
|
||||||
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
|
<div class="flex items-center h-5">
|
||||||
</p>
|
<i class="fab fa-google-drive w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mt-4">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<i class="fas fa-clone w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2 text-sm">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300">When duplicating configurations, you may need to re-enter sensitive credentials for security reasons. Make sure to edit duplicated configurations to add any required credentials.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+184
-92
@@ -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,154 +60,199 @@ 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>
|
||||||
}
|
}
|
||||||
</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)
|
||||||
|
}
|
||||||
+937
-720
File diff suppressed because it is too large
Load Diff
+183
-203
@@ -6,102 +6,91 @@ import (
|
|||||||
|
|
||||||
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
|
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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-99
@@ -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
|
||||||
if isLoggedIn(ctx) {
|
</p>
|
||||||
<div class="space-y-8">
|
</div>
|
||||||
<!-- Feature Cards -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
if isLoggedIn(ctx) {
|
||||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
<!-- Logged-in User View -->
|
||||||
<div class="p-6">
|
<div class="space-y-8">
|
||||||
<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">
|
<!-- Feature Cards -->
|
||||||
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
</div>
|
<!-- Transfer Configs Card -->
|
||||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
|
<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">
|
||||||
<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="p-6 text-center">
|
||||||
<div class="flex justify-center">
|
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 dark:bg-blue-900">
|
||||||
<a href="/configs" class="btn-primary inline-flex items-center">
|
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||||
<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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+534
-526
File diff suppressed because it is too large
Load Diff
+151
-157
@@ -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-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-file-alt mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||||
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+275
-189
@@ -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;
|
||||||
@@ -158,9 +206,7 @@ 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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+410
-409
@@ -2,19 +2,20 @@ package components
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"time"
|
"time"
|
||||||
"fmt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 {
|
||||||
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/v%s", AppVersion))
|
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", AppVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTemplateContext creates a new context with user information from Gin's context
|
// CreateTemplateContext creates a new context with user information from Gin's context
|
||||||
@@ -41,465 +42,465 @@ 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>
|
<script>
|
||||||
// Initialize Notyf and make it available globally
|
// Immediate theme application
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
// This script runs before the DOM is fully loaded
|
||||||
window.notyfInstance = new Notyf({
|
(function() {
|
||||||
duration: 3000,
|
const isDark = localStorage.theme === 'dark' ||
|
||||||
position: {
|
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
if (isDark) {
|
||||||
</script>
|
document.documentElement.classList.add('dark');
|
||||||
<script>
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
tailwind.config = {
|
document.body.classList.add('dark');
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
// Apply dark theme to containers
|
||||||
extend: {
|
const containers = ['jobs-container', 'configs-container'];
|
||||||
colors: {
|
containers.forEach(function(id) {
|
||||||
primary: {
|
const container = document.getElementById(id);
|
||||||
50: '#f0f9ff',
|
if (container) {
|
||||||
100: '#e0f2fe',
|
container.classList.add('dark');
|
||||||
200: '#bae6fd',
|
container.style.backgroundColor = '#111827';
|
||||||
300: '#7dd3fc',
|
}
|
||||||
400: '#38bdf8',
|
});
|
||||||
500: '#0ea5e9',
|
});
|
||||||
600: '#0284c7',
|
|
||||||
700: '#0369a1',
|
|
||||||
800: '#075985',
|
|
||||||
900: '#0c4a6e',
|
|
||||||
950: '#082f49',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
50: '#f8fafc',
|
|
||||||
100: '#f1f5f9',
|
|
||||||
200: '#e2e8f0',
|
|
||||||
300: '#cbd5e1',
|
|
||||||
400: '#94a3b8',
|
|
||||||
500: '#64748b',
|
|
||||||
600: '#475569',
|
|
||||||
700: '#334155',
|
|
||||||
800: '#1e293b',
|
|
||||||
900: '#0f172a',
|
|
||||||
950: '#020617',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
'custom': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
|
||||||
'custom-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
</script>
|
</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;">
|
||||||
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>
|
||||||
|
<!-- 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>
|
||||||
|
<!-- Application scripts -->
|
||||||
|
<script defer src="/static/dist/app.js"></script>
|
||||||
|
<script defer src="/static/dist/init.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
@@ -536,17 +537,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 +556,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"
|
||||||
@@ -569,4 +570,4 @@ func getUserEmail(ctx context.Context) string {
|
|||||||
// Helper function to get current year
|
// Helper function to get current year
|
||||||
func getCurrentYear() string {
|
func getCurrentYear() string {
|
||||||
return time.Now().Format("2006")
|
return time.Now().Format("2006")
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-116
@@ -7,131 +7,129 @@ import (
|
|||||||
|
|
||||||
templ Login(ctx context.Context, errorMessage string) {
|
templ Login(ctx context.Context, errorMessage string) {
|
||||||
@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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
-122
@@ -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,127 +68,129 @@ 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">
|
</div>
|
||||||
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
|
<div x-show="deleteAfterTransfer" class="mt-2">
|
||||||
</p>
|
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/30 dark:text-red-300" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
|
<span>Warning: This will permanently delete the original files</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="flex items-center">
|
||||||
<label for="skip_processed_files" class="flex items-center cursor-pointer">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<input type="checkbox" id="skip_processed_files" name="skip_processed_files" x-model="skipProcessedFiles"
|
||||||
<input id="skip_processed_files" name="skip_processed_files" type="checkbox" x-model="skipProcessedFiles"
|
class="sr-only peer" :value="skipProcessedFiles ? '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-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>
|
||||||
:value="skipProcessedFiles ? 'true' : 'false'"
|
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Skip files that have already been processed</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="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
|
||||||
Skip files that have already been processed
|
|
||||||
</div>
|
|
||||||
</label>
|
</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>
|
||||||
|
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Files with the same hash that have been successfully processed before will be skipped
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mt-6">
|
||||||
<label for="max_concurrent_transfers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Concurrent Transfers</label>
|
<label for="max_concurrent_transfers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<div class="flex items-center">
|
Concurrent Transfers: <span x-text="maxConcurrentTransfers"></span>
|
||||||
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
</label>
|
||||||
class="form-range w-2/3 h-2 bg-secondary-200 dark:bg-secondary-700 rounded-lg appearance-none cursor-pointer"
|
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
||||||
min="1" max="32" step="1"
|
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||||
x-model="maxConcurrentTransfers"
|
min="1" max="32" step="1" x-model="maxConcurrentTransfers">
|
||||||
/>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<span class="ml-4 text-secondary-700 dark:text-secondary-300" x-text="maxConcurrentTransfers"></span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
|
||||||
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() {
|
||||||
<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()
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Select the rclone command to use for this configuration.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Command flags container - will be populated via HTMX -->
|
||||||
|
<div id="command-flags-container" class="mt-4"></div>
|
||||||
|
|
||||||
|
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-flag text-gray-400 dark:text-gray-500"></i>
|
||||||
</div>
|
</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() {
|
||||||
|
<div
|
||||||
|
hx-get="/api/rclone/commands"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<!-- Loading placeholder -->
|
||||||
|
<option value="">Loading commands...</option>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
templ SourceSelection() {
|
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>
|
||||||
@@ -194,23 +199,22 @@ templ SourceSelection() {
|
|||||||
<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>
|
||||||
</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>
|
||||||
@@ -219,9 +223,241 @@ templ DestinationSelection() {
|
|||||||
<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>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// RcloneCommandOptionsContent renders the command options organized by category
|
||||||
|
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string) {
|
||||||
|
<select id="command_id" name="command_id" x-model="commandId"
|
||||||
|
hx-get="/api/rclone/command-flags"
|
||||||
|
hx-target="#command-flags-container"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="[name='command_id']"
|
||||||
|
@change="updateCommandRequirements()"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||||
|
<option value="">Select command...</option>
|
||||||
|
for _, category := range categories {
|
||||||
|
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
|
||||||
|
<optgroup label={ category }>
|
||||||
|
for _, cmd := range commands {
|
||||||
|
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
|
// RcloneCommandFlagsContent renders the command flags for a selected command
|
||||||
|
templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
|
||||||
|
if command == nil {
|
||||||
|
<div class="p-4 text-red-500">Command not found</div>
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="font-medium text-gray-900 dark:text-white mb-2">{ command.Name }</h4>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{ command.Description }</p>
|
||||||
|
|
||||||
|
if len(command.Flags) > 0 {
|
||||||
|
<div class="mb-3">
|
||||||
|
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Command Flags:</h5>
|
||||||
|
<div class="space-y-3">
|
||||||
|
for _, flag := range command.Flags {
|
||||||
|
if flag.DataType == "bool" {
|
||||||
|
<!-- Boolean flag (simple checkbox) -->
|
||||||
|
<div class="flex items-start mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={ fmt.Sprintf("flag_%d", flag.ID) }
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||||
|
/>
|
||||||
|
<div class="ml-3">
|
||||||
|
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{ flag.Name } - { flag.Description }
|
||||||
|
</label>
|
||||||
|
if flag.DefaultValue != "" {
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Default: { flag.DefaultValue }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<!-- Non-boolean flag (requires value) -->
|
||||||
|
<div class="flex items-start mb-4 w-full">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||||
|
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
onclick="toggleFlagValue(this)"
|
||||||
|
/>
|
||||||
|
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{ flag.Name } - { flag.Description }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full mt-2">
|
||||||
|
@renderFlagInput(flag)
|
||||||
|
if flag.DefaultValue != "" {
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript to handle enabling/disabling flag value inputs -->
|
||||||
|
<script>
|
||||||
|
function toggleFlagValue(checkbox) {
|
||||||
|
const inputId = checkbox.getAttribute('data-input-id');
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.disabled = !checkbox.checked;
|
||||||
|
if (checkbox.checked) {
|
||||||
|
// Focus on the input when enabled
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also enable/disable the hidden input for flag ID
|
||||||
|
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
|
||||||
|
const hiddenInput = document.getElementById(hiddenId);
|
||||||
|
if (hiddenInput) {
|
||||||
|
hiddenInput.disabled = !checkbox.checked;
|
||||||
|
console.log('Toggle hidden input:', hiddenId, checkbox.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For debugging
|
||||||
|
console.log('Toggle flag value:', inputId, checkbox.checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all flag inputs on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const checkboxes = document.querySelectorAll('input[id^="flag_enable_"]');
|
||||||
|
checkboxes.forEach(function(checkbox) {
|
||||||
|
const inputId = checkbox.getAttribute('data-input-id');
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
if (input) {
|
||||||
|
input.disabled = !checkbox.checked;
|
||||||
|
|
||||||
|
// Also initialize the hidden input
|
||||||
|
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
|
||||||
|
const hiddenInput = document.getElementById(hiddenId);
|
||||||
|
if (hiddenInput) {
|
||||||
|
hiddenInput.disabled = !checkbox.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span class="font-medium">Usage Examples</span>
|
||||||
|
</div>
|
||||||
|
if command.Name == "ls" ||
|
||||||
|
command.Name == "lsd" ||
|
||||||
|
command.Name == "lsl" ||
|
||||||
|
command.Name == "lsjson" ||
|
||||||
|
command.Name == "lsf" ||
|
||||||
|
command.Name == "delete" ||
|
||||||
|
command.Name == "purge" ||
|
||||||
|
command.Name == "rmdirs" ||
|
||||||
|
command.Name == "mkdir" ||
|
||||||
|
command.Name == "touch" ||
|
||||||
|
command.Name == "md5sum" ||
|
||||||
|
command.Name == "sha1sum" ||
|
||||||
|
command.Name == "sha256sum" ||
|
||||||
|
command.Name == "size" ||
|
||||||
|
command.Name == "stat" ||
|
||||||
|
command.Name == "version" {
|
||||||
|
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path</code>
|
||||||
|
} else {
|
||||||
|
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path dest:path</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="p-3 bg-gray-50 text-gray-600 rounded-lg border border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 text-sm">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
<span>This command doesn't have any specific flags.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to render appropriate input based on flag data type
|
||||||
|
templ renderFlagInput(flag db.RcloneCommandFlag) {
|
||||||
|
if flag.DataType == "int" {
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder={ flag.DefaultValue }
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
disabled
|
||||||
|
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||||
|
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
/>
|
||||||
|
} else if flag.DataType == "float" {
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
step="0.01"
|
||||||
|
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder={ flag.DefaultValue }
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
disabled
|
||||||
|
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||||
|
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
/>
|
||||||
|
} else {
|
||||||
|
<!-- Default to text input for string and other types -->
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||||
|
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder={ flag.DefaultValue }
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<!-- Hidden input to include this flag ID when checked -->
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="command_flags"
|
||||||
|
value={ fmt.Sprintf("%d", flag.ID) }
|
||||||
|
disabled
|
||||||
|
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||||
|
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||||
|
/>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,109 +1,79 @@
|
|||||||
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-model="destPort"
|
|
||||||
required
|
|
||||||
min="1"
|
|
||||||
max="65535"
|
|
||||||
value="21"
|
|
||||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
placeholder="21"/>
|
|
||||||
</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>
|
||||||
}
|
}
|
||||||
@@ -1,39 +1,41 @@
|
|||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary h-10 whitespace-nowrap"
|
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
@click="checkPath(destinationPath, 'dest')"
|
@click="checkPath(destinationPath, 'dest')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||||
Check Location
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
|
||||||
</button>
|
</svg>
|
||||||
|
Check Location
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,97 @@
|
|||||||
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">
|
<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</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="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_endpoint" 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_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_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="nextcloud_username" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud username
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="NextCloud password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="remote.php/dav/files/username/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Connection Tip</h3>
|
||||||
|
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
||||||
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="destinationKeyFile"
|
||||||
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>
|
||||||
}
|
}
|
||||||
@@ -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_endpoint" 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_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="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="webdav_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="WebDAV password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files on the WebDAV server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Integration Note</h3>
|
||||||
|
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
|
||||||
</div>
|
</div>
|
||||||
<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"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" />
|
||||||
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>
|
||||||
}
|
}
|
||||||
@@ -1,39 +1,41 @@
|
|||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary h-10 whitespace-nowrap"
|
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
@click="checkPath(sourcePath, 'source')"
|
@click="checkPath(sourcePath, 'source')"
|
||||||
>
|
>
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||||
Check Location
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
|
||||||
</button>
|
</svg>
|
||||||
|
Check Location
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,97 @@
|
|||||||
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">
|
<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 Key</label>
|
<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="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="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_endpoint" 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_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_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="nextcloud_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="NextCloud password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your NextCloud account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="remote.php/dav/files/username/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Connection Tip</h3>
|
||||||
|
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
}
|
}
|
||||||
@@ -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_endpoint" 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_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_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="webdav_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="WebDAV password" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Your WebDAV account password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||||
|
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
placeholder="/path/to/files" />
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Path to your files on the WebDAV server
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">Integration Note</h3>
|
||||||
|
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
}
|
}
|
||||||
+152
-82
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
}
|
|
||||||
|
<div class="p-6">
|
||||||
if data.SuccessMessage != "" {
|
<!-- Security Alert -->
|
||||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
<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">
|
||||||
// Show important messages with a different style and icon
|
<div class="flex items-start">
|
||||||
<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">
|
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
<div class="flex items-center">
|
<div>
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
<h4 class="text-sm font-medium mb-1">Security information</h4>
|
||||||
<span class="block sm:inline font-bold">{ data.SuccessMessage }</span>
|
<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">
|
||||||
</div>
|
<li>Each code can only be used once.</li>
|
||||||
} else {
|
<li>Store these codes in a secure password manager.</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>These codes allow access to your account - keep them safe!</li>
|
||||||
<div class="flex items-center">
|
<li>For security, you can only view codes immediately after generating them.</li>
|
||||||
<i class="fas fa-info-circle mr-2"></i>
|
</ul>
|
||||||
<span class="block sm:inline">{ data.SuccessMessage }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="space-y-8">
|
|
||||||
if len(data.BackupCodes) > 0 {
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Your Backup Codes</h3>
|
|
||||||
|
|
||||||
<div class="p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 rounded-lg mb-6">
|
|
||||||
<div class="flex items-start">
|
|
||||||
<div class="flex-shrink-0 mt-0.5">
|
|
||||||
<i class="fas fa-shield-alt text-amber-600 dark:text-amber-400 text-lg"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">Security information</h4>
|
|
||||||
<div class="mt-1 text-sm text-amber-700 dark:text-amber-400 space-y-2">
|
|
||||||
<p><strong>These codes are your backup method to access your account.</strong></p>
|
|
||||||
<ul class="list-disc pl-5 space-y-1">
|
|
||||||
<li>Each code can only be used once.</li>
|
|
||||||
<li>Store these codes in a secure password manager.</li>
|
|
||||||
<li>These codes allow access to your account - keep them safe!</li>
|
|
||||||
<li>For security, you can only view codes immediately after generating them.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
+190
-125
@@ -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')
|
<!-- Verification Section -->
|
||||||
).map(el => el.textContent.trim());
|
<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">
|
||||||
// Create content for the file
|
<div class="flex items-center space-x-3">
|
||||||
const content =
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
"2FA Backup Codes - Keep these safe!\n" +
|
<i class="fas fa-check-circle text-blue-600 dark:text-blue-300"></i>
|
||||||
"=====================================\n\n" +
|
</div>
|
||||||
codes.join("\n") +
|
<div>
|
||||||
"\n\n" +
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
2. Verify Setup
|
||||||
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
</h3>
|
||||||
"Each code can only be used once. Keep these codes safe and secure.";
|
<p class="text-sm text-gray-500 dark:text-gray-400">Enter the code from your authenticator app</p>
|
||||||
|
</div>
|
||||||
// Create blob and download link
|
</div>
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
</div>
|
||||||
const url = window.URL.createObjectURL(blob);
|
<div class="p-6">
|
||||||
const a = document.createElement('a');
|
<form
|
||||||
a.href = url;
|
method="POST"
|
||||||
a.download = '2fa-backup-codes.txt';
|
action="/profile/2fa/verify"
|
||||||
|
class="space-y-4"
|
||||||
// Trigger download
|
x-data="{ code: '', loading: false }"
|
||||||
document.body.appendChild(a);
|
@submit="loading = true">
|
||||||
a.click();
|
<div>
|
||||||
|
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||||
// Cleanup
|
Enter the 6-digit code from your authenticator app
|
||||||
window.URL.revokeObjectURL(url);
|
</label>
|
||||||
document.body.removeChild(a);
|
<input
|
||||||
}
|
type="text"
|
||||||
</script>
|
id="code"
|
||||||
|
name="code"
|
||||||
|
x-model="code"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="Enter 6-digit code"
|
||||||
|
required/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||||
|
x-bind:disabled="code.length !== 6 || loading">
|
||||||
|
<span x-show="!loading" class="flex items-center justify-center">
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
|
Verify and Enable 2FA
|
||||||
|
</span>
|
||||||
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Verifying...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if len(data.BackupCodes) > 0 {
|
||||||
|
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
3. Save Backup Codes
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Store these backup codes in a safe place</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800" role="alert">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Important:</span> These codes can be used to access your account if you lose your authenticator device. Each code can only be used once. Keep these codes safe and secure.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary w-full"
|
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">
|
x-bind:disabled="code.length !== 6 || loading">
|
||||||
<span x-show="!loading">Verify</span>
|
<span x-show="!loading" class="flex items-center justify-center">
|
||||||
<span x-show="loading" class="flex items-center justify-center">
|
<i class="fas fa-shield-alt mr-2"></i>
|
||||||
<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">
|
Verify
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
</span>
|
||||||
<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>
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
</svg>
|
<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">
|
||||||
Verifying...
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
</span>
|
<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>
|
||||||
</button>
|
</svg>
|
||||||
|
Verifying...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="text-center">
|
<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">
|
<a href="/login/backup-code" class="text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||||
Use a backup code instead
|
Use a backup code instead
|
||||||
</a>
|
</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>
|
||||||
</form>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-6 text-center">
|
</div>
|
||||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
|
||||||
Lost your device?
|
<!-- Help Notice -->
|
||||||
<br/>
|
<div class="mt-6 max-w-md mx-auto">
|
||||||
You can use one of your backup codes instead of the 6-digit code.
|
<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">
|
||||||
</p>
|
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||||
|
<p>Two-factor authentication adds an extra layer of security to your account. After entering your password, you'll need to provide a code from your authenticator app to verify your identity.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
||||||
|
}
|
||||||
+147
-172
@@ -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="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="card p-12 flex flex-col items-center justify-center text-center">
|
<i class="fas fa-exclamation-triangle flex-shrink-0 inline w-5 h-5 me-3"></i>
|
||||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
<span class="sr-only">Info</span>
|
||||||
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
<div>
|
||||||
</div>
|
<span class="font-medium">No users found!</span> Click the "Add User" button to create your first user.
|
||||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
|
|
||||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by adding a new user.</p>
|
|
||||||
<div class="mt-6">
|
|
||||||
<a href="/admin/users/new" class="btn-primary">
|
|
||||||
<i class="fas fa-user-plus mr-2"></i>
|
|
||||||
Add User
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 -->
|
|
||||||
<div class="mt-8 text-center">
|
<!-- Help Notice -->
|
||||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
<div class="mt-8">
|
||||||
<i class="fas fa-shield-alt mr-1 text-primary-500"></i>
|
<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
|
User accounts provide secure access to the GoMFT application with role-based permissions
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,163 +137,125 @@ 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="mb-6 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span>{ data.ErrorMessage }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
if data.ErrorMessage != "" {
|
|
||||||
<div class="mb-6">
|
|
||||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg" role="alert">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
|
||||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<form
|
<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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,8 +12,7 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
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
|
golang.org/x/crypto v0.36.0
|
||||||
golang.org/x/crypto v0.35.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
|
||||||
)
|
)
|
||||||
@@ -23,7 +22,6 @@ require (
|
|||||||
github.com/bytedance/sonic v1.12.9 // indirect
|
github.com/bytedance/sonic v1.12.9 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
@@ -45,15 +43,13 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
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/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
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
|
|||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -107,15 +106,15 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
|||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGenerateAndValidateToken(t *testing.T) {
|
|
||||||
// Setup test data
|
|
||||||
userID := uint(1)
|
|
||||||
email := "test@example.com"
|
|
||||||
secret := "test-jwt-secret"
|
|
||||||
expirationTime := 1 * time.Hour
|
|
||||||
|
|
||||||
// Generate a token
|
|
||||||
token, err := GenerateToken(userID, email, secret, expirationTime)
|
|
||||||
assert.NoError(t, err, "Should not return an error when generating a token")
|
|
||||||
assert.NotEmpty(t, token, "Token should not be empty")
|
|
||||||
|
|
||||||
// Validate the token
|
|
||||||
claims, err := ValidateToken(token, secret)
|
|
||||||
assert.NoError(t, err, "Should not return an error when validating a valid token")
|
|
||||||
assert.NotNil(t, claims, "Claims should not be nil")
|
|
||||||
assert.Equal(t, userID, claims.UserID, "UserID should match")
|
|
||||||
assert.Equal(t, email, claims.Email, "Email should match")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidToken(t *testing.T) {
|
|
||||||
// Setup
|
|
||||||
invalidToken := "invalid.token.string"
|
|
||||||
secret := "test-jwt-secret"
|
|
||||||
|
|
||||||
// Validate the invalid token
|
|
||||||
claims, err := ValidateToken(invalidToken, secret)
|
|
||||||
assert.Error(t, err, "Should return an error when validating an invalid token")
|
|
||||||
assert.Nil(t, claims, "Claims should be nil for an invalid token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpiredToken(t *testing.T) {
|
|
||||||
// Setup test data
|
|
||||||
userID := uint(1)
|
|
||||||
email := "test@example.com"
|
|
||||||
secret := "test-jwt-secret"
|
|
||||||
expirationTime := -1 * time.Hour // Negative duration to create an expired token
|
|
||||||
|
|
||||||
// Generate an expired token
|
|
||||||
token, err := GenerateToken(userID, email, secret, expirationTime)
|
|
||||||
assert.NoError(t, err, "Should not return an error when generating a token")
|
|
||||||
|
|
||||||
// Validate the expired token
|
|
||||||
claims, err := ValidateToken(token, secret)
|
|
||||||
assert.Error(t, err, "Should return an error when validating an expired token")
|
|
||||||
assert.Nil(t, claims, "Claims should be nil for an expired token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidSecret(t *testing.T) {
|
|
||||||
// Setup test data
|
|
||||||
userID := uint(1)
|
|
||||||
email := "test@example.com"
|
|
||||||
secret := "original-secret"
|
|
||||||
wrongSecret := "wrong-secret"
|
|
||||||
expirationTime := 1 * time.Hour
|
|
||||||
|
|
||||||
// Generate a token with the original secret
|
|
||||||
token, err := GenerateToken(userID, email, secret, expirationTime)
|
|
||||||
assert.NoError(t, err, "Should not return an error when generating a token")
|
|
||||||
|
|
||||||
// Validate the token with the wrong secret
|
|
||||||
claims, err := ValidateToken(token, wrongSecret)
|
|
||||||
assert.Error(t, err, "Should return an error when validating with the wrong secret")
|
|
||||||
assert.Nil(t, claims, "Claims should be nil when validating with the wrong secret")
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockDB is a mock implementation of *gorm.DB for testing
|
|
||||||
type MockDB struct {
|
|
||||||
mock.Mock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Where(query interface{}, args ...interface{}) *gorm.DB {
|
|
||||||
m.Called(query, args)
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Order(value interface{}) *gorm.DB {
|
|
||||||
m.Called(value)
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Limit(limit int) *gorm.DB {
|
|
||||||
m.Called(limit)
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Find(dest interface{}, conds ...interface{}) *gorm.DB {
|
|
||||||
m.Called(dest, conds)
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Create(value interface{}) *gorm.DB {
|
|
||||||
m.Called(value)
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Delete(value interface{}, conds ...interface{}) *gorm.DB {
|
|
||||||
m.Called(value, conds)
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Model(value interface{}) *gorm.DB {
|
|
||||||
m.Called(value)
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockDB) Count(count *int64) *gorm.DB {
|
|
||||||
m.Called(count)
|
|
||||||
*count = 10 // Mock count for testing
|
|
||||||
return &gorm.DB{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultPasswordPolicy(t *testing.T) {
|
|
||||||
policy := DefaultPasswordPolicy()
|
|
||||||
|
|
||||||
assert.Equal(t, 8, policy.MinLength, "Default min length should be 8")
|
|
||||||
assert.True(t, policy.RequireUppercase, "Should require uppercase by default")
|
|
||||||
assert.True(t, policy.RequireLowercase, "Should require lowercase by default")
|
|
||||||
assert.True(t, policy.RequireNumbers, "Should require numbers by default")
|
|
||||||
assert.True(t, policy.RequireSpecial, "Should require special chars by default")
|
|
||||||
assert.Equal(t, 90, policy.ExpirationDays, "Default expiration should be 90 days")
|
|
||||||
assert.Equal(t, 5, policy.HistoryCount, "Default history count should be 5")
|
|
||||||
assert.True(t, policy.DisallowCommon, "Should disallow common passwords by default")
|
|
||||||
assert.Equal(t, 5, policy.MaxLoginAttempts, "Default max login attempts should be 5")
|
|
||||||
assert.Equal(t, 15*time.Minute, policy.LockoutDuration, "Default lockout duration should be 15 minutes")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidatePassword(t *testing.T) {
|
|
||||||
policy := DefaultPasswordPolicy()
|
|
||||||
|
|
||||||
// Test valid password
|
|
||||||
err := ValidatePassword("Test1234!", policy)
|
|
||||||
assert.NoError(t, err, "Valid password should pass validation")
|
|
||||||
|
|
||||||
// Test password too short
|
|
||||||
err = ValidatePassword("Test1!", policy)
|
|
||||||
assert.Error(t, err, "Password shorter than minimum length should fail")
|
|
||||||
assert.Contains(t, err.Error(), "at least 8 characters")
|
|
||||||
|
|
||||||
// Test password without uppercase
|
|
||||||
err = ValidatePassword("test1234!", policy)
|
|
||||||
assert.Error(t, err, "Password without uppercase should fail")
|
|
||||||
assert.Contains(t, err.Error(), "uppercase letter")
|
|
||||||
|
|
||||||
// Test password without lowercase
|
|
||||||
err = ValidatePassword("TEST1234!", policy)
|
|
||||||
assert.Error(t, err, "Password without lowercase should fail")
|
|
||||||
assert.Contains(t, err.Error(), "lowercase letter")
|
|
||||||
|
|
||||||
// Test password without numbers
|
|
||||||
err = ValidatePassword("TestTest!", policy)
|
|
||||||
assert.Error(t, err, "Password without numbers should fail")
|
|
||||||
assert.Contains(t, err.Error(), "number")
|
|
||||||
|
|
||||||
// Test password without special characters
|
|
||||||
err = ValidatePassword("Test1234", policy)
|
|
||||||
assert.Error(t, err, "Password without special characters should fail")
|
|
||||||
assert.Contains(t, err.Error(), "special character")
|
|
||||||
|
|
||||||
// Test common password - we need to disable other validations to test just the common password check
|
|
||||||
customPolicy := DefaultPasswordPolicy()
|
|
||||||
customPolicy.RequireUppercase = false
|
|
||||||
customPolicy.RequireLowercase = false
|
|
||||||
customPolicy.RequireNumbers = false
|
|
||||||
customPolicy.RequireSpecial = false
|
|
||||||
|
|
||||||
err = ValidatePassword("password", customPolicy)
|
|
||||||
assert.Error(t, err, "Common password should fail even with relaxed requirements")
|
|
||||||
assert.Contains(t, err.Error(), "common or easily guessable")
|
|
||||||
|
|
||||||
// Test with custom policy (all validations disabled)
|
|
||||||
verySimplePolicy := PasswordPolicy{
|
|
||||||
MinLength: 6,
|
|
||||||
RequireUppercase: false,
|
|
||||||
RequireLowercase: false,
|
|
||||||
RequireNumbers: false,
|
|
||||||
RequireSpecial: false,
|
|
||||||
DisallowCommon: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ValidatePassword("simple", verySimplePolicy)
|
|
||||||
assert.NoError(t, err, "Simple password should pass with all validations disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComparePasswords(t *testing.T) {
|
|
||||||
// Generate a hashed password
|
|
||||||
plainPassword := "TestPassword123!"
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
|
||||||
assert.NoError(t, err, "Password hashing should not error")
|
|
||||||
|
|
||||||
// Test valid password comparison
|
|
||||||
err = ComparePasswords(string(hashedPassword), plainPassword)
|
|
||||||
assert.NoError(t, err, "Correct password should match hash")
|
|
||||||
|
|
||||||
// Test invalid password comparison
|
|
||||||
err = ComparePasswords(string(hashedPassword), "WrongPassword123!")
|
|
||||||
assert.Error(t, err, "Incorrect password should not match hash")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsPasswordExpired(t *testing.T) {
|
|
||||||
policy := DefaultPasswordPolicy()
|
|
||||||
|
|
||||||
// Test password within expiration period
|
|
||||||
lastChange := time.Now().Add(-80 * 24 * time.Hour) // 80 days ago
|
|
||||||
assert.False(t, IsPasswordExpired(lastChange, policy), "Password changed 80 days ago should not be expired")
|
|
||||||
|
|
||||||
// Test expired password
|
|
||||||
lastChange = time.Now().Add(-100 * 24 * time.Hour) // 100 days ago
|
|
||||||
assert.True(t, IsPasswordExpired(lastChange, policy), "Password changed 100 days ago should be expired")
|
|
||||||
|
|
||||||
// Test with expiration disabled
|
|
||||||
customPolicy := PasswordPolicy{
|
|
||||||
ExpirationDays: 0, // Disabled
|
|
||||||
}
|
|
||||||
lastChange = time.Now().Add(-1000 * 24 * time.Hour) // 1000 days ago
|
|
||||||
assert.False(t, IsPasswordExpired(lastChange, customPolicy), "Password should not expire when expiration is disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCommonPassword(t *testing.T) {
|
|
||||||
// Test with common passwords
|
|
||||||
assert.True(t, isCommonPassword("password"), "Should detect 'password' as common")
|
|
||||||
assert.True(t, isCommonPassword("admin123"), "Should detect 'admin123' as common")
|
|
||||||
assert.True(t, isCommonPassword("QWERTY"), "Should detect 'QWERTY' as common (case insensitive)")
|
|
||||||
|
|
||||||
// Test with uncommon passwords
|
|
||||||
assert.False(t, isCommonPassword("G4x8qT2!pL9z"), "Should not detect complex password as common")
|
|
||||||
assert.False(t, isCommonPassword("UniquePassword123!"), "Should not detect unique password as common")
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
|
||||||
// Create a temporary directory for testing
|
|
||||||
tempDir, err := os.MkdirTemp("", "gomft-test-*")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp directory: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// Set up test environment variables
|
|
||||||
testEnvVars := map[string]string{
|
|
||||||
"SERVER_ADDRESS": ":9090",
|
|
||||||
"DATA_DIR": filepath.Join(tempDir, "data"),
|
|
||||||
"BACKUP_DIR": filepath.Join(tempDir, "backups"),
|
|
||||||
"JWT_SECRET": "test-jwt-secret",
|
|
||||||
"BASE_URL": "http://test.example.com",
|
|
||||||
"EMAIL_ENABLED": "true",
|
|
||||||
"EMAIL_HOST": "smtp.test.com",
|
|
||||||
"EMAIL_PORT": "2525",
|
|
||||||
"EMAIL_USERNAME": "test@example.com",
|
|
||||||
"EMAIL_PASSWORD": "test-password",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a temporary .env file
|
|
||||||
envContent := ""
|
|
||||||
for key, value := range testEnvVars {
|
|
||||||
envContent += key + "=" + value + "\n"
|
|
||||||
os.Setenv(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save temporary .env file
|
|
||||||
envPath := filepath.Join(tempDir, ".env")
|
|
||||||
if err := os.WriteFile(envPath, []byte(envContent), 0644); err != nil {
|
|
||||||
t.Fatalf("Failed to write test .env file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a symlink to the temp .env file from the project root
|
|
||||||
// This is a hack for testing, as the Load() function looks for .env in the root
|
|
||||||
currentEnv := ".env"
|
|
||||||
// Backup existing .env if it exists
|
|
||||||
if _, err := os.Stat(currentEnv); err == nil {
|
|
||||||
if err := os.Rename(currentEnv, currentEnv+".bak"); err != nil {
|
|
||||||
t.Fatalf("Failed to backup existing .env file: %v", err)
|
|
||||||
}
|
|
||||||
defer os.Rename(currentEnv+".bak", currentEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temporary .env for test
|
|
||||||
if err := os.WriteFile(currentEnv, []byte(envContent), 0644); err != nil {
|
|
||||||
t.Fatalf("Failed to write test .env file: %v", err)
|
|
||||||
}
|
|
||||||
defer os.Remove(currentEnv)
|
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
cfg, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to load configuration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify loaded configuration matches expected values
|
|
||||||
if cfg.ServerAddress != testEnvVars["SERVER_ADDRESS"] {
|
|
||||||
t.Errorf("Expected ServerAddress to be %s, got %s", testEnvVars["SERVER_ADDRESS"], cfg.ServerAddress)
|
|
||||||
}
|
|
||||||
if cfg.DataDir != testEnvVars["DATA_DIR"] {
|
|
||||||
t.Errorf("Expected DataDir to be %s, got %s", testEnvVars["DATA_DIR"], cfg.DataDir)
|
|
||||||
}
|
|
||||||
if cfg.BackupDir != testEnvVars["BACKUP_DIR"] {
|
|
||||||
t.Errorf("Expected BackupDir to be %s, got %s", testEnvVars["BACKUP_DIR"], cfg.BackupDir)
|
|
||||||
}
|
|
||||||
if cfg.JWTSecret != testEnvVars["JWT_SECRET"] {
|
|
||||||
t.Errorf("Expected JWTSecret to be %s, got %s", testEnvVars["JWT_SECRET"], cfg.JWTSecret)
|
|
||||||
}
|
|
||||||
if cfg.BaseURL != testEnvVars["BASE_URL"] {
|
|
||||||
t.Errorf("Expected BaseURL to be %s, got %s", testEnvVars["BASE_URL"], cfg.BaseURL)
|
|
||||||
}
|
|
||||||
if !cfg.Email.Enabled {
|
|
||||||
t.Errorf("Expected Email.Enabled to be true")
|
|
||||||
}
|
|
||||||
if cfg.Email.Host != testEnvVars["EMAIL_HOST"] {
|
|
||||||
t.Errorf("Expected Email.Host to be %s, got %s", testEnvVars["EMAIL_HOST"], cfg.Email.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditLog represents an audit trail entry in the system
|
||||||
|
type AuditLog struct {
|
||||||
|
gorm.Model
|
||||||
|
Action string `gorm:"size:50;not null;index"`
|
||||||
|
EntityType string `gorm:"size:50;not null;index"`
|
||||||
|
EntityID uint `gorm:"not null;index"`
|
||||||
|
UserID uint `gorm:"not null;index"`
|
||||||
|
Details AuditLogDetails `gorm:"type:json"`
|
||||||
|
Timestamp time.Time `gorm:"not null;index;default:CURRENT_TIMESTAMP"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogDetails is a custom type for storing audit log details as JSON
|
||||||
|
type AuditLogDetails map[string]interface{}
|
||||||
|
|
||||||
|
// Scan implements the sql.Scanner interface
|
||||||
|
func (d *AuditLogDetails) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*d = make(AuditLogDetails)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, ok := value.([]byte)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(bytes, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver.Valuer interface
|
||||||
|
func (d AuditLogDetails) Value() (driver.Value, error) {
|
||||||
|
if d == nil {
|
||||||
|
return json.Marshal(make(map[string]interface{}))
|
||||||
|
}
|
||||||
|
return json.Marshal(d)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderType represents the type of authentication provider
|
||||||
|
type ProviderType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ProviderTypeAuthentik represents an Authentik authentication provider
|
||||||
|
ProviderTypeAuthentik ProviderType = "authentik"
|
||||||
|
|
||||||
|
// ProviderTypeOIDC represents an OpenID Connect authentication provider
|
||||||
|
ProviderTypeOIDC ProviderType = "oidc"
|
||||||
|
|
||||||
|
// ProviderTypeSAML represents a SAML authentication provider
|
||||||
|
ProviderTypeSAML ProviderType = "saml"
|
||||||
|
|
||||||
|
// ProviderTypeOAuth2 represents an OAuth2 authentication provider
|
||||||
|
ProviderTypeOAuth2 ProviderType = "oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthProvider represents an external authentication provider configuration
|
||||||
|
type AuthProvider struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Type ProviderType `gorm:"not null" json:"type"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ProviderURL string `json:"provider_url"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"-"` // Not returned in JSON responses
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
Scopes string `json:"scopes"`
|
||||||
|
AttributeMapping string `json:"attribute_mapping"`
|
||||||
|
Config string `json:"-"` // Stores provider-specific configuration
|
||||||
|
IconURL string `json:"icon_url"` // URL to provider icon
|
||||||
|
SuccessfulLogins int `json:"successful_logins"`
|
||||||
|
LastUsed sql.NullTime `json:"last_used"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Unmarshalled config
|
||||||
|
configData map[string]interface{} `gorm:"-" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig returns the unmarshalled configuration data
|
||||||
|
func (p *AuthProvider) GetConfig() (map[string]interface{}, error) {
|
||||||
|
if p.configData == nil && p.Config != "" {
|
||||||
|
err := json.Unmarshal([]byte(p.Config), &p.configData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.configData == nil {
|
||||||
|
p.configData = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.configData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfig sets the configuration data and marshals it to JSON
|
||||||
|
func (p *AuthProvider) SetConfig(data map[string]interface{}) error {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Config = string(jsonData)
|
||||||
|
p.configData = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExternalUserIdentity represents a user identity from an external authentication provider
|
||||||
|
type ExternalUserIdentity struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null" json:"user_id"`
|
||||||
|
ProviderID uint `gorm:"not null" json:"provider_id"`
|
||||||
|
ProviderType ProviderType `gorm:"not null" json:"provider_type"`
|
||||||
|
ExternalID string `gorm:"not null" json:"external_id"`
|
||||||
|
Email string `gorm:"not null" json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Groups string `json:"groups"` // JSON array of groups
|
||||||
|
LastLogin sql.NullTime `json:"last_login"`
|
||||||
|
ProviderData string `json:"-"` // Raw data from provider
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Foreign key relationships
|
||||||
|
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||||
|
Provider AuthProvider `gorm:"foreignKey:ProviderID" json:"-"`
|
||||||
|
|
||||||
|
// Unmarshalled provider data
|
||||||
|
providerDataObj map[string]interface{} `gorm:"-" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderData returns the unmarshalled provider data
|
||||||
|
func (e *ExternalUserIdentity) GetProviderData() (map[string]interface{}, error) {
|
||||||
|
if e.providerDataObj == nil && e.ProviderData != "" {
|
||||||
|
err := json.Unmarshal([]byte(e.ProviderData), &e.providerDataObj)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.providerDataObj == nil {
|
||||||
|
e.providerDataObj = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.providerDataObj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProviderData sets the provider data and marshals it to JSON
|
||||||
|
func (e *ExternalUserIdentity) SetProviderData(data map[string]interface{}) error {
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e.ProviderData = string(jsonData)
|
||||||
|
e.providerDataObj = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroups returns the unmarshalled groups
|
||||||
|
func (e *ExternalUserIdentity) GetGroups() ([]string, error) {
|
||||||
|
var groups []string
|
||||||
|
if e.Groups == "" {
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(e.Groups), &groups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroups sets the groups and marshals them to JSON
|
||||||
|
func (e *ExternalUserIdentity) SetGroups(groups []string) error {
|
||||||
|
jsonData, err := json.Marshal(groups)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Groups = string(jsonData)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAllAuthProviders returns all authentication providers
|
||||||
|
func (db *DB) GetAllAuthProviders(ctx context.Context) ([]AuthProvider, error) {
|
||||||
|
var providers []AuthProvider
|
||||||
|
tx := db.WithContext(ctx).Order("name asc").Find(&providers)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get auth providers: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthProviderByID retrieves an authentication provider by ID
|
||||||
|
func (db *DB) GetAuthProviderByID(ctx context.Context, id uint) (*AuthProvider, error) {
|
||||||
|
var provider AuthProvider
|
||||||
|
tx := db.WithContext(ctx).First(&provider, id)
|
||||||
|
if tx.Error != nil {
|
||||||
|
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("auth provider not found: %d", id)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get auth provider: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return &provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAuthProvider creates a new authentication provider
|
||||||
|
func (db *DB) CreateAuthProvider(ctx context.Context, provider *AuthProvider) error {
|
||||||
|
tx := db.WithContext(ctx).Create(provider)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to create auth provider: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAuthProvider updates an existing authentication provider
|
||||||
|
func (db *DB) UpdateAuthProvider(ctx context.Context, provider *AuthProvider) error {
|
||||||
|
tx := db.WithContext(ctx).Save(provider)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to update auth provider: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAuthProvider deletes an authentication provider by ID
|
||||||
|
func (db *DB) DeleteAuthProvider(ctx context.Context, id uint) error {
|
||||||
|
tx := db.WithContext(ctx).Delete(&AuthProvider{}, id)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to delete auth provider: %w", tx.Error)
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("auth provider not found: %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnabledAuthProviders returns all enabled authentication providers
|
||||||
|
func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) {
|
||||||
|
var providers []AuthProvider
|
||||||
|
tx := db.WithContext(ctx).Where("enabled = ?", true).Order("name asc").Find(&providers)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get enabled auth providers: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthProviderByType returns authentication providers of a specific type
|
||||||
|
func (db *DB) GetAuthProviderByType(ctx context.Context, providerType ProviderType) ([]AuthProvider, error) {
|
||||||
|
var providers []AuthProvider
|
||||||
|
tx := db.WithContext(ctx).Where("type = ?", providerType).Order("name asc").Find(&providers)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get auth providers by type: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExternalUserIdentitiesByProviderID returns all external user identities for a specific provider
|
||||||
|
func (db *DB) GetExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) ([]ExternalUserIdentity, error) {
|
||||||
|
var identities []ExternalUserIdentity
|
||||||
|
tx := db.WithContext(ctx).Where("provider_id = ?", providerID).Find(&identities)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get external user identities: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return identities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountExternalUserIdentitiesByProviderID counts the number of external user identities for a specific provider
|
||||||
|
func (db *DB) CountExternalUserIdentitiesByProviderID(ctx context.Context, providerID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
tx := db.WithContext(ctx).Model(&ExternalUserIdentity{}).Where("provider_id = ?", providerID).Count(&count)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return 0, fmt.Errorf("failed to count external user identities: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExternalUserIdentity gets an external user identity by provider ID and external ID
|
||||||
|
func (db *DB) GetExternalUserIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) {
|
||||||
|
var identity ExternalUserIdentity
|
||||||
|
tx := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity)
|
||||||
|
if tx.Error != nil {
|
||||||
|
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil // Not found, but not an error
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get external user identity: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return &identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateExternalUserIdentity creates a new external user identity
|
||||||
|
func (db *DB) CreateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||||
|
tx := db.WithContext(ctx).Create(identity)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to create external user identity: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateExternalUserIdentity updates an existing external user identity
|
||||||
|
func (db *DB) UpdateExternalUserIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||||
|
tx := db.WithContext(ctx).Save(identity)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to update external user identity: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteExternalUserIdentity deletes an external user identity by ID
|
||||||
|
func (db *DB) DeleteExternalUserIdentity(ctx context.Context, id uint) error {
|
||||||
|
tx := db.WithContext(ctx).Delete(&ExternalUserIdentity{}, id)
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to delete external user identity: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAuthProviderLastUsed updates the last used timestamp and increments the successful logins counter
|
||||||
|
func (db *DB) UpdateAuthProviderLastUsed(ctx context.Context, providerID uint) error {
|
||||||
|
tx := db.WithContext(ctx).Model(&AuthProvider{}).
|
||||||
|
Where("id = ?", providerID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"last_used": gorm.Expr("NOW()"),
|
||||||
|
"successful_logins": gorm.Expr("successful_logins + 1"),
|
||||||
|
})
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to update auth provider last used: %w", tx.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+647
-10
@@ -1,7 +1,10 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/starfleetcptn/gomft/internal/db/migrations"
|
"github.com/starfleetcptn/gomft/internal/db/migrations"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ type User struct {
|
|||||||
TwoFactorSecret string `gorm:"type:varchar(32)"`
|
TwoFactorSecret string `gorm:"type:varchar(32)"`
|
||||||
TwoFactorEnabled bool `gorm:"default:false"`
|
TwoFactorEnabled bool `gorm:"default:false"`
|
||||||
BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
|
BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
|
||||||
|
Roles []Role `gorm:"many2many:user_roles"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -116,9 +121,13 @@ type TransferConfig struct {
|
|||||||
UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services
|
UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services
|
||||||
GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed
|
GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed
|
||||||
// General fields
|
// General fields
|
||||||
ArchivePath string `form:"archive_path"`
|
ArchivePath string `form:"archive_path"`
|
||||||
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
|
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
|
||||||
RcloneFlags string `form:"rclone_flags"`
|
RcloneFlags string `form:"rclone_flags"`
|
||||||
|
// Rclone command fields
|
||||||
|
CommandID uint `gorm:"default:1" form:"command_id"` // Default to 'copy' command ID (1)
|
||||||
|
CommandFlags string `form:"command_flags"` // JSON string of selected flags
|
||||||
|
CommandFlagValues string `form:"command_flag_values"` // JSON string of flag values by ID
|
||||||
DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"`
|
DeleteAfterTransfer *bool `gorm:"default:false" form:"delete_after_transfer"`
|
||||||
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
|
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
|
||||||
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
|
MaxConcurrentTransfers int `gorm:"default:4" form:"max_concurrent_transfers"` // Number of concurrent file transfers
|
||||||
@@ -186,6 +195,9 @@ func (j *Job) SetConfigIDsList(ids []uint) {
|
|||||||
// Join with commas
|
// Join with commas
|
||||||
j.ConfigIDs = strings.Join(strIDs, ",")
|
j.ConfigIDs = strings.Join(strIDs, ",")
|
||||||
|
|
||||||
|
// Debug log the final ConfigIDs string
|
||||||
|
log.Printf("SetConfigIDsList: Setting ConfigIDs to: %s (from %v)", j.ConfigIDs, ids)
|
||||||
|
|
||||||
// If there's at least one ID, set ConfigID to the first one for backward compatibility
|
// If there's at least one ID, set ConfigID to the first one for backward compatibility
|
||||||
if len(ids) > 0 {
|
if len(ids) > 0 {
|
||||||
j.ConfigID = ids[0]
|
j.ConfigID = ids[0]
|
||||||
@@ -215,6 +227,8 @@ type JobHistory struct {
|
|||||||
BytesTransferred int64
|
BytesTransferred int64
|
||||||
FilesTransferred int
|
FilesTransferred int
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileMetadata stores information about processed files
|
// FileMetadata stores information about processed files
|
||||||
@@ -241,6 +255,31 @@ type DB struct {
|
|||||||
*gorm.DB
|
*gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RcloneCommand represents a command available in rclone
|
||||||
|
type RcloneCommand struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
Name string `gorm:"not null;uniqueIndex"`
|
||||||
|
Description string `gorm:"not null"`
|
||||||
|
Category string `gorm:"not null;index"`
|
||||||
|
IsAdvanced bool `gorm:"not null;default:false"`
|
||||||
|
Flags []RcloneCommandFlag `gorm:"foreignKey:CommandID;constraint:OnDelete:CASCADE"`
|
||||||
|
CreatedAt time.Time `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RcloneCommandFlag represents a flag that can be used with an rclone command
|
||||||
|
type RcloneCommandFlag struct {
|
||||||
|
ID uint `gorm:"primarykey"`
|
||||||
|
CommandID uint `gorm:"not null;index"`
|
||||||
|
Command RcloneCommand `gorm:"foreignKey:CommandID"`
|
||||||
|
Name string `gorm:"not null;index"`
|
||||||
|
ShortName string
|
||||||
|
Description string `gorm:"not null"`
|
||||||
|
DataType string `gorm:"not null"` // string, int, bool, etc.
|
||||||
|
IsRequired bool `gorm:"not null;default:false"`
|
||||||
|
DefaultValue string
|
||||||
|
CreatedAt time.Time `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
func Initialize(dbPath string) (*DB, error) {
|
func Initialize(dbPath string) (*DB, error) {
|
||||||
// Create directory if it doesn't exist
|
// Create directory if it doesn't exist
|
||||||
dir := filepath.Dir(dbPath)
|
dir := filepath.Dir(dbPath)
|
||||||
@@ -255,7 +294,7 @@ func Initialize(dbPath string) (*DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize and run migrations
|
// Initialize and run migrations
|
||||||
m := migrations.InitMigrations(db)
|
m := migrations.GetMigrations(db)
|
||||||
if err := m.Migrate(); err != nil {
|
if err := m.Migrate(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to run migrations: %v", err)
|
return nil, fmt.Errorf("failed to run migrations: %v", err)
|
||||||
}
|
}
|
||||||
@@ -263,6 +302,18 @@ func Initialize(dbPath string) (*DB, error) {
|
|||||||
return &DB{DB: db}, nil
|
return &DB{DB: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReopenWithoutMigrations reopens the database connection without running migrations
|
||||||
|
// This should be used when temporarily closing and reopening the database
|
||||||
|
func ReopenWithoutMigrations(dbPath string) (*DB, error) {
|
||||||
|
// Open database connection with modernc.org/sqlite driver
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{DB: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) Close() error {
|
func (db *DB) Close() error {
|
||||||
sqlDB, err := db.DB.DB()
|
sqlDB, err := db.DB.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -376,8 +427,25 @@ func (db *DB) GetJob(id uint) (*Job, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) UpdateJob(job *Job) error {
|
func (db *DB) UpdateJob(job *Job) error {
|
||||||
|
log.Printf("UpdateJob: Updating job ID: %d, ConfigIDs: %s", job.ID, job.ConfigIDs)
|
||||||
|
|
||||||
// Use Omit to prevent GORM from updating or creating a new config
|
// Use Omit to prevent GORM from updating or creating a new config
|
||||||
return db.Omit("Config").Save(job).Error
|
return db.Model(&Job{}).
|
||||||
|
Where("id = ?", job.ID).
|
||||||
|
Omit("Config").
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"name": job.Name,
|
||||||
|
"config_id": job.ConfigID,
|
||||||
|
"config_ids": job.ConfigIDs, // Explicitly update config_ids
|
||||||
|
"schedule": job.Schedule,
|
||||||
|
"enabled": job.Enabled,
|
||||||
|
"webhook_enabled": job.WebhookEnabled,
|
||||||
|
"webhook_url": job.WebhookURL,
|
||||||
|
"webhook_secret": job.WebhookSecret,
|
||||||
|
"webhook_headers": job.WebhookHeaders,
|
||||||
|
"notify_on_success": job.NotifyOnSuccess,
|
||||||
|
"notify_on_failure": job.NotifyOnFailure,
|
||||||
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) DeleteJob(id uint) error {
|
func (db *DB) DeleteJob(id uint) error {
|
||||||
@@ -585,7 +653,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
case "webdav":
|
case "webdav":
|
||||||
args := []string{
|
args := []string{
|
||||||
"config", "create", sourceName, "webdav",
|
"config", "create", sourceName, "webdav",
|
||||||
"url", config.SourceHost,
|
"url", config.SourceEndpoint,
|
||||||
"user", config.SourceUser,
|
"user", config.SourceUser,
|
||||||
"pass", config.SourcePassword,
|
"pass", config.SourcePassword,
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
@@ -600,7 +668,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
case "nextcloud":
|
case "nextcloud":
|
||||||
args := []string{
|
args := []string{
|
||||||
"config", "create", sourceName, "webdav",
|
"config", "create", sourceName, "webdav",
|
||||||
"url", config.SourceHost,
|
"url", config.SourceEndpoint,
|
||||||
"user", config.SourceUser,
|
"user", config.SourceUser,
|
||||||
"pass", config.SourcePassword,
|
"pass", config.SourcePassword,
|
||||||
"vendor", "nextcloud",
|
"vendor", "nextcloud",
|
||||||
@@ -692,6 +760,9 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destName := fmt.Sprintf("dest_%d", config.ID)
|
destName := fmt.Sprintf("dest_%d", config.ID)
|
||||||
|
|
||||||
|
fmt.Println("config", config)
|
||||||
|
|
||||||
switch config.DestinationType {
|
switch config.DestinationType {
|
||||||
case "sftp":
|
case "sftp":
|
||||||
args := []string{
|
args := []string{
|
||||||
@@ -807,7 +878,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
case "webdav":
|
case "webdav":
|
||||||
args := []string{
|
args := []string{
|
||||||
"config", "create", destName, "webdav",
|
"config", "create", destName, "webdav",
|
||||||
"url", config.DestHost,
|
"url", config.DestEndpoint,
|
||||||
"user", config.DestUser,
|
"user", config.DestUser,
|
||||||
"pass", config.DestPassword,
|
"pass", config.DestPassword,
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
@@ -822,7 +893,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
|||||||
case "nextcloud":
|
case "nextcloud":
|
||||||
args := []string{
|
args := []string{
|
||||||
"config", "create", destName, "webdav",
|
"config", "create", destName, "webdav",
|
||||||
"url", config.DestHost,
|
"url", config.DestEndpoint,
|
||||||
"user", config.DestUser,
|
"user", config.DestUser,
|
||||||
"pass", config.DestPassword,
|
"pass", config.DestPassword,
|
||||||
"vendor", "nextcloud",
|
"vendor", "nextcloud",
|
||||||
@@ -965,7 +1036,21 @@ func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return configs, nil
|
// Create a map for quick lookup
|
||||||
|
configMap := make(map[uint]TransferConfig)
|
||||||
|
for _, config := range configs {
|
||||||
|
configMap[config.ID] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new slice with configs in the correct order
|
||||||
|
orderedConfigs := make([]TransferConfig, 0, len(configs))
|
||||||
|
for _, configID := range configIDs {
|
||||||
|
if config, exists := configMap[configID]; exists {
|
||||||
|
orderedConfigs = append(orderedConfigs, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedConfigs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil
|
// GetSkipProcessedFiles returns the value of SkipProcessedFiles with a default if nil
|
||||||
@@ -1473,3 +1558,555 @@ func (tc *TransferConfig) GetUseBuiltinAuthDest() bool {
|
|||||||
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
|
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
|
||||||
tc.UseBuiltinAuthDest = &value
|
tc.UseBuiltinAuthDest = &value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasRole checks if the user has a specific role
|
||||||
|
func (u *User) HasRole(roleName string) bool {
|
||||||
|
for _, role := range u.Roles {
|
||||||
|
if role.Name == roleName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermission checks if the user has a specific permission through any of their roles
|
||||||
|
func (u *User) HasPermission(permission string) bool {
|
||||||
|
for _, role := range u.Roles {
|
||||||
|
if role.HasPermission(permission) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoles returns all roles assigned to the user
|
||||||
|
func (u *User) GetRoles(tx *gorm.DB) ([]Role, error) {
|
||||||
|
var roles []Role
|
||||||
|
err := tx.Model(u).Association("Roles").Find(&roles)
|
||||||
|
return roles, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignRole assigns a role to the user
|
||||||
|
func (u *User) AssignRole(tx *gorm.DB, roleID uint, assignedByID uint) error {
|
||||||
|
var role Role
|
||||||
|
if err := tx.First(&role, roleID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return role.AssignToUser(tx, u.ID, assignedByID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnassignRole removes a role from the user
|
||||||
|
func (u *User) UnassignRole(tx *gorm.DB, roleID uint, unassignedByID uint) error {
|
||||||
|
var role Role
|
||||||
|
if err := tx.First(&role, roleID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return role.UnassignFromUser(tx, u.ID, unassignedByID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role operations
|
||||||
|
func (db *DB) CreateRole(role *Role) error {
|
||||||
|
return db.Create(role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetRole(id uint) (*Role, error) {
|
||||||
|
var role Role
|
||||||
|
err := db.First(&role, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetRoleByName(name string) (*Role, error) {
|
||||||
|
var role Role
|
||||||
|
err := db.Where("name = ?", name).First(&role).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateRole(role *Role) error {
|
||||||
|
return db.Save(role).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteRole(id uint) error {
|
||||||
|
var role Role
|
||||||
|
if err := db.First(&role, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if role.IsSystemRole() {
|
||||||
|
return errors.New("cannot delete system role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
tx := db.Begin()
|
||||||
|
if err := tx.Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete role assignments
|
||||||
|
if err := tx.Exec("DELETE FROM user_roles WHERE role_id = ?", id).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the role
|
||||||
|
if err := tx.Delete(&role).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListRoles() ([]Role, error) {
|
||||||
|
var roles []Role
|
||||||
|
err := db.Find(&roles).Error
|
||||||
|
return roles, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetUserRoles(userID uint) ([]Role, error) {
|
||||||
|
var user User
|
||||||
|
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user.Roles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignRoleToUser assigns a role to a user
|
||||||
|
func (db *DB) AssignRoleToUser(roleID, userID, assignedByID uint) error {
|
||||||
|
var role Role
|
||||||
|
if err := db.First(&role, roleID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
if err := tx.Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := role.AssignToUser(tx, userID, assignedByID); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnassignRoleFromUser removes a role from a user
|
||||||
|
func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error {
|
||||||
|
var role Role
|
||||||
|
if err := db.First(&role, roleID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
if err := tx.Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := role.UnassignFromUser(tx, userID, unassignedByID); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// User struct method to set password with secure hashing
|
||||||
|
func (u *User) SetPassword(password string) error {
|
||||||
|
// Validate password length
|
||||||
|
if len(password) < 8 {
|
||||||
|
return fmt.Errorf("password must be at least 8 characters long")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password using bcrypt
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the hashed password
|
||||||
|
u.PasswordHash = string(hashedPassword)
|
||||||
|
u.LastPasswordChange = time.Now()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) CheckPassword(password string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommands returns all rclone commands
|
||||||
|
func (db *DB) GetRcloneCommands() ([]RcloneCommand, error) {
|
||||||
|
var commands []RcloneCommand
|
||||||
|
err := db.Find(&commands).Error
|
||||||
|
return commands, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommand returns a specific rclone command by ID
|
||||||
|
func (db *DB) GetRcloneCommand(id uint) (*RcloneCommand, error) {
|
||||||
|
var command RcloneCommand
|
||||||
|
err := db.First(&command, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandByName returns a specific rclone command by name
|
||||||
|
func (db *DB) GetRcloneCommandByName(name string) (*RcloneCommand, error) {
|
||||||
|
var command RcloneCommand
|
||||||
|
err := db.Where("name = ?", name).First(&command).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandsInCategory returns all commands in a specific category
|
||||||
|
func (db *DB) GetRcloneCommandsInCategory(category string) ([]RcloneCommand, error) {
|
||||||
|
var commands []RcloneCommand
|
||||||
|
err := db.Where("category = ?", category).Find(&commands).Error
|
||||||
|
return commands, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandFlag returns a specific flag by ID
|
||||||
|
func (db *DB) GetRcloneCommandFlag(id uint) (*RcloneCommandFlag, error) {
|
||||||
|
var flag RcloneCommandFlag
|
||||||
|
err := db.First(&flag, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &flag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandFlagByName returns a specific flag by name for a command
|
||||||
|
func (db *DB) GetRcloneCommandFlagByName(commandID uint, name string) (*RcloneCommandFlag, error) {
|
||||||
|
var flag RcloneCommandFlag
|
||||||
|
err := db.Where("command_id = ? AND name = ?", commandID, name).First(&flag).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &flag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandFlags returns all flags for a specific command
|
||||||
|
func (db *DB) GetRcloneCommandFlags(commandID uint) ([]RcloneCommandFlag, error) {
|
||||||
|
var flags []RcloneCommandFlag
|
||||||
|
err := db.Where("command_id = ?", commandID).Find(&flags).Error
|
||||||
|
return flags, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandWithFlags returns a command with all its flags
|
||||||
|
func (db *DB) GetRcloneCommandWithFlags(commandID uint) (*RcloneCommand, error) {
|
||||||
|
var command RcloneCommand
|
||||||
|
err := db.Preload("Flags").First(&command, commandID).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &command, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRcloneCommand builds an rclone command string with the specified command and flags
|
||||||
|
func (db *DB) BuildRcloneCommand(commandName string, flags map[string]string) (string, error) {
|
||||||
|
// Get the command details
|
||||||
|
command, err := db.GetRcloneCommandByName(commandName)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("command not found: %s", commandName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start building the command string
|
||||||
|
cmdStr := "rclone " + command.Name
|
||||||
|
|
||||||
|
// Get all flags for this command
|
||||||
|
allFlags, err := db.GetRcloneCommandFlags(command.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get flags for command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of flag details for easy lookup
|
||||||
|
flagDetails := make(map[string]RcloneCommandFlag)
|
||||||
|
for _, f := range allFlags {
|
||||||
|
flagDetails[f.Name] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the flags to the command
|
||||||
|
for name, value := range flags {
|
||||||
|
// Check if the flag exists for this command
|
||||||
|
flag, exists := flagDetails[name]
|
||||||
|
if !exists {
|
||||||
|
return "", fmt.Errorf("invalid flag for command %s: %s", commandName, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different flag types
|
||||||
|
switch flag.DataType {
|
||||||
|
case "bool":
|
||||||
|
if value == "true" {
|
||||||
|
cmdStr += " " + name
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cmdStr += " " + name + " " + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmdStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRcloneFlags validates if the provided flags are valid for the command
|
||||||
|
func (db *DB) ValidateRcloneFlags(commandName string, flags map[string]string) (bool, map[string]string) {
|
||||||
|
// Initialize errors map
|
||||||
|
errors := make(map[string]string)
|
||||||
|
|
||||||
|
// Get the command details
|
||||||
|
command, err := db.GetRcloneCommandByName(commandName)
|
||||||
|
if err != nil {
|
||||||
|
errors["command"] = "Command not found: " + commandName
|
||||||
|
return false, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all flags for this command
|
||||||
|
allFlags, err := db.GetRcloneCommandFlags(command.ID)
|
||||||
|
if err != nil {
|
||||||
|
errors["command"] = "Failed to get flags for command"
|
||||||
|
return false, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of flag details for easy lookup
|
||||||
|
flagDetails := make(map[string]RcloneCommandFlag)
|
||||||
|
for _, f := range allFlags {
|
||||||
|
flagDetails[f.Name] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each provided flag
|
||||||
|
for name, value := range flags {
|
||||||
|
// Check if the flag exists for this command
|
||||||
|
flag, exists := flagDetails[name]
|
||||||
|
if !exists {
|
||||||
|
errors[name] = "Invalid flag for command " + commandName
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the flag value based on data type
|
||||||
|
switch flag.DataType {
|
||||||
|
case "int":
|
||||||
|
if _, err := strconv.Atoi(value); err != nil {
|
||||||
|
errors[name] = "Value must be an integer"
|
||||||
|
}
|
||||||
|
case "float":
|
||||||
|
if _, err := strconv.ParseFloat(value, 64); err != nil {
|
||||||
|
errors[name] = "Value must be a number"
|
||||||
|
}
|
||||||
|
case "bool":
|
||||||
|
if value != "true" && value != "false" {
|
||||||
|
errors[name] = "Value must be true or false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required flags
|
||||||
|
for _, flag := range allFlags {
|
||||||
|
if flag.IsRequired {
|
||||||
|
if _, provided := flags[flag.Name]; !provided {
|
||||||
|
errors[flag.Name] = "This flag is required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(errors) == 0, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCategories returns all unique categories of rclone commands
|
||||||
|
func (db *DB) GetRcloneCategories() ([]string, error) {
|
||||||
|
var categories []string
|
||||||
|
err := db.Model(&RcloneCommand{}).Distinct("category").Pluck("category", &categories).Error
|
||||||
|
return categories, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandsByAdvanced returns commands filtered by their advanced status
|
||||||
|
func (db *DB) GetRcloneCommandsByAdvanced(isAdvanced bool) ([]RcloneCommand, error) {
|
||||||
|
var commands []RcloneCommand
|
||||||
|
err := db.Where("is_advanced = ?", isAdvanced).Find(&commands).Error
|
||||||
|
return commands, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchRcloneCommands searches for commands by name or description
|
||||||
|
func (db *DB) SearchRcloneCommands(query string) ([]RcloneCommand, error) {
|
||||||
|
var commands []RcloneCommand
|
||||||
|
searchQuery := "%" + query + "%"
|
||||||
|
err := db.Where("name LIKE ? OR description LIKE ?", searchQuery, searchQuery).Find(&commands).Error
|
||||||
|
return commands, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneFlagUsage returns a human-readable usage example for a flag
|
||||||
|
func (flag *RcloneCommandFlag) GetUsageExample() string {
|
||||||
|
switch flag.DataType {
|
||||||
|
case "bool":
|
||||||
|
return flag.Name
|
||||||
|
case "int":
|
||||||
|
return fmt.Sprintf("%s=<number>", flag.Name)
|
||||||
|
case "float":
|
||||||
|
return fmt.Sprintf("%s=<decimal>", flag.Name)
|
||||||
|
case "string":
|
||||||
|
return fmt.Sprintf("%s=<text>", flag.Name)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s=<value>", flag.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRcloneCommandUsage returns a basic usage example for a command with its required flags
|
||||||
|
func (db *DB) GetRcloneCommandUsage(commandID uint) (string, error) {
|
||||||
|
command, err := db.GetRcloneCommandWithFlags(commandID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := fmt.Sprintf("rclone %s [flags] <source> <dest>", command.Name)
|
||||||
|
|
||||||
|
// Add basic usage examples for required flags
|
||||||
|
requiredFlags := []string{}
|
||||||
|
for _, flag := range command.Flags {
|
||||||
|
if flag.IsRequired {
|
||||||
|
requiredFlags = append(requiredFlags, flag.GetUsageExample())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(requiredFlags) > 0 {
|
||||||
|
usage += "\n\nRequired flags:\n " + strings.Join(requiredFlags, "\n ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRcloneFlags parses a string of rclone flags into a map
|
||||||
|
func ParseRcloneFlags(flagsStr string) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
if flagsStr == "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the flags string by spaces
|
||||||
|
parts := strings.Fields(flagsStr)
|
||||||
|
|
||||||
|
for i := 0; i < len(parts); i++ {
|
||||||
|
part := parts[i]
|
||||||
|
|
||||||
|
// Check if it's a flag (starts with --)
|
||||||
|
if strings.HasPrefix(part, "--") {
|
||||||
|
// Remove the -- prefix
|
||||||
|
flagName := part
|
||||||
|
|
||||||
|
// Check if the flag has a value
|
||||||
|
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "--") {
|
||||||
|
// Next part is a value
|
||||||
|
result[flagName] = parts[i+1]
|
||||||
|
i++ // Skip the value in the next iteration
|
||||||
|
} else {
|
||||||
|
// Flag without value, treat as boolean
|
||||||
|
result[flagName] = "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderRcloneCommandHelp generates a help text for a command with its flags
|
||||||
|
func (db *DB) RenderRcloneCommandHelp(commandID uint) (string, error) {
|
||||||
|
command, err := db.GetRcloneCommandWithFlags(commandID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the help text
|
||||||
|
help := fmt.Sprintf("COMMAND: %s\n", command.Name)
|
||||||
|
help += fmt.Sprintf("DESCRIPTION: %s\n\n", command.Description)
|
||||||
|
help += "FLAGS:\n"
|
||||||
|
|
||||||
|
// Group flags by required status
|
||||||
|
var requiredFlags, optionalFlags []RcloneCommandFlag
|
||||||
|
for _, flag := range command.Flags {
|
||||||
|
if flag.IsRequired {
|
||||||
|
requiredFlags = append(requiredFlags, flag)
|
||||||
|
} else {
|
||||||
|
optionalFlags = append(optionalFlags, flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add required flags
|
||||||
|
if len(requiredFlags) > 0 {
|
||||||
|
help += " Required:\n"
|
||||||
|
for _, flag := range requiredFlags {
|
||||||
|
shortName := ""
|
||||||
|
if flag.ShortName != "" {
|
||||||
|
shortName = fmt.Sprintf(" (-%s)", flag.ShortName)
|
||||||
|
}
|
||||||
|
help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description)
|
||||||
|
if flag.DataType != "bool" && flag.DefaultValue != "" {
|
||||||
|
help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optional flags
|
||||||
|
if len(optionalFlags) > 0 {
|
||||||
|
help += "\n Optional:\n"
|
||||||
|
for _, flag := range optionalFlags {
|
||||||
|
shortName := ""
|
||||||
|
if flag.ShortName != "" {
|
||||||
|
shortName = fmt.Sprintf(" (-%s)", flag.ShortName)
|
||||||
|
}
|
||||||
|
help += fmt.Sprintf(" %s%s - %s\n", flag.Name, shortName, flag.Description)
|
||||||
|
if flag.DataType != "bool" && flag.DefaultValue != "" {
|
||||||
|
help += fmt.Sprintf(" Default: %s\n", flag.DefaultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return help, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]RcloneCommandFlag, error) {
|
||||||
|
flags, err := db.GetRcloneCommandFlags(commandID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
flagsMap := make(map[uint]RcloneCommandFlag)
|
||||||
|
for _, flag := range flags {
|
||||||
|
flagsMap[flag.ID] = flag
|
||||||
|
}
|
||||||
|
|
||||||
|
return flagsMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnabledAuthProviders returns all enabled authentication providers
|
||||||
|
// func (db *DB) GetEnabledAuthProviders(ctx context.Context) ([]AuthProvider, error) {
|
||||||
|
// var providers []AuthProvider
|
||||||
|
// result := db.WithContext(ctx).Where("enabled = ?", true).Find(&providers)
|
||||||
|
// return providers, result.Error
|
||||||
|
// }
|
||||||
|
|
||||||
|
// GetExternalIdentity retrieves an external identity by provider ID and external ID
|
||||||
|
func (db *DB) GetExternalIdentity(ctx context.Context, providerID uint, externalID string) (*ExternalUserIdentity, error) {
|
||||||
|
var identity ExternalUserIdentity
|
||||||
|
result := db.WithContext(ctx).Where("provider_id = ? AND external_id = ?", providerID, externalID).First(&identity)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
return &identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateExternalIdentity creates a new external user identity
|
||||||
|
func (db *DB) CreateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||||
|
return db.WithContext(ctx).Create(identity).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateExternalIdentity updates an existing external user identity
|
||||||
|
func (db *DB) UpdateExternalIdentity(ctx context.Context, identity *ExternalUserIdentity) error {
|
||||||
|
return db.WithContext(ctx).Save(identity).Error
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,250 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDeleteTransferConfigEdgeCases tests edge cases for the DeleteTransferConfig function
|
|
||||||
func TestDeleteTransferConfigEdgeCases(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
testUser := &User{
|
|
||||||
Email: "config-edge-test@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err := db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create multiple configs
|
|
||||||
configs := make([]*TransferConfig, 5)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
config := &TransferConfig{
|
|
||||||
Name: fmt.Sprintf("Edge Config %d", i),
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: fmt.Sprintf("/source/path/%d", i),
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: fmt.Sprintf("/destination/path/%d", i),
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateTransferConfig(config)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
configs[i] = config
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete them in reverse order
|
|
||||||
for i := 4; i >= 0; i-- {
|
|
||||||
err = db.DeleteTransferConfig(configs[i].ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
_, err = db.GetTransferConfig(configs[i].ID)
|
|
||||||
assert.Error(t, err, "Config should be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test deleting a config that has a job associated with it
|
|
||||||
configWithJob := &TransferConfig{
|
|
||||||
Name: "Config with Job",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/source/path/job",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/destination/path/job",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateTransferConfig(configWithJob)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a job for this config
|
|
||||||
job := &Job{
|
|
||||||
Name: "Job for Config",
|
|
||||||
ConfigID: configWithJob.ID,
|
|
||||||
Schedule: "0 * * * *",
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateJob(job)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Try to delete the config - this should fail due to foreign key constraint
|
|
||||||
err = db.DeleteTransferConfig(configWithJob.ID)
|
|
||||||
assert.Error(t, err, "Should not be able to delete config with associated jobs")
|
|
||||||
assert.Contains(t, err.Error(), "jobs are using this configuration", "Error should mention jobs")
|
|
||||||
|
|
||||||
// Delete the job first
|
|
||||||
err = db.DeleteJob(job.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Now delete the config - this should succeed
|
|
||||||
err = db.DeleteTransferConfig(configWithJob.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
_, err = db.GetTransferConfig(configWithJob.ID)
|
|
||||||
assert.Error(t, err, "Config should be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteJobEdgeCases tests edge cases for the DeleteJob function
|
|
||||||
func TestDeleteJobEdgeCases(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
testUser := &User{
|
|
||||||
Email: "job-edge-test@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err := db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a test config
|
|
||||||
config := &TransferConfig{
|
|
||||||
Name: "Config for Job Edge Cases",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/source/path",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/destination/path",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateTransferConfig(config)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create multiple jobs
|
|
||||||
jobs := make([]*Job, 5)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
job := &Job{
|
|
||||||
Name: fmt.Sprintf("Edge Job %d", i),
|
|
||||||
ConfigID: config.ID,
|
|
||||||
Schedule: "0 * * * *",
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateJob(job)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
jobs[i] = job
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete them in reverse order
|
|
||||||
for i := 4; i >= 0; i-- {
|
|
||||||
err = db.DeleteJob(jobs[i].ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
_, err = db.GetJob(jobs[i].ID)
|
|
||||||
assert.Error(t, err, "Job should be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a job with history records
|
|
||||||
jobWithHistory := &Job{
|
|
||||||
Name: "Job with History",
|
|
||||||
ConfigID: config.ID,
|
|
||||||
Schedule: "0 * * * *",
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateJob(jobWithHistory)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create history records
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
startTime := time.Now().Add(time.Duration(-i) * time.Hour)
|
|
||||||
endTime := startTime.Add(30 * time.Minute)
|
|
||||||
history := &JobHistory{
|
|
||||||
JobID: jobWithHistory.ID,
|
|
||||||
StartTime: startTime,
|
|
||||||
EndTime: &endTime,
|
|
||||||
Status: "completed",
|
|
||||||
BytesTransferred: int64(1024 * (i + 1)),
|
|
||||||
FilesTransferred: i + 1,
|
|
||||||
}
|
|
||||||
err = db.CreateJobHistory(history)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now delete the job - this should succeed even with history records
|
|
||||||
// (due to foreign key constraints in the database)
|
|
||||||
err = db.DeleteJob(jobWithHistory.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
_, err = db.GetJob(jobWithHistory.ID)
|
|
||||||
assert.Error(t, err, "Job should be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInitializeEdgeCases tests edge cases for the Initialize function
|
|
||||||
func TestInitializeEdgeCases(t *testing.T) {
|
|
||||||
// Test with a read-only directory (if possible)
|
|
||||||
tempDir, err := os.MkdirTemp("", "gomft_test_readonly")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp directory: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// Try to make the directory read-only
|
|
||||||
// Note: This may not work on all systems due to permissions
|
|
||||||
origPerms, err := os.Stat(tempDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to stat directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to make it read-only
|
|
||||||
err = os.Chmod(tempDir, 0400) // read-only
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("Warning: Could not set directory to read-only: %v", err)
|
|
||||||
t.Skip("Could not set directory to read-only, skipping test")
|
|
||||||
}
|
|
||||||
defer os.Chmod(tempDir, origPerms.Mode()) // restore original permissions
|
|
||||||
|
|
||||||
dbPath := filepath.Join(tempDir, "readonly.db")
|
|
||||||
// This might fail because the directory is read-only
|
|
||||||
db, err := Initialize(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
// Expected error due to read-only directory
|
|
||||||
t.Logf("Got expected error for read-only directory: %v", err)
|
|
||||||
} else {
|
|
||||||
// If it succeeded, clean up
|
|
||||||
t.Logf("Warning: DB initialization succeeded even with read-only directory!")
|
|
||||||
err = db.Close()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCloseEdgeCases tests edge cases for the Close function
|
|
||||||
func TestCloseEdgeCases(t *testing.T) {
|
|
||||||
// Create a temporary database
|
|
||||||
tempDir, err := os.MkdirTemp("", "gomft_test_close_edge")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
dbPath := filepath.Join(tempDir, "close_edge.db")
|
|
||||||
db, err := Initialize(dbPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test calling methods after close
|
|
||||||
sqlDB, err := db.DB.DB()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get initial stats
|
|
||||||
stats := sqlDB.Stats()
|
|
||||||
t.Logf("Initial stats: MaxOpenConnections=%d, OpenConnections=%d, InUse=%d",
|
|
||||||
stats.MaxOpenConnections, stats.OpenConnections, stats.InUse)
|
|
||||||
|
|
||||||
// Close the DB
|
|
||||||
err = db.Close()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Try to get stats again - this might fail
|
|
||||||
stats = sqlDB.Stats()
|
|
||||||
t.Logf("After close stats: MaxOpenConnections=%d, OpenConnections=%d, InUse=%d",
|
|
||||||
stats.MaxOpenConnections, stats.OpenConnections, stats.InUse)
|
|
||||||
|
|
||||||
// Verify that DB operations fail after close
|
|
||||||
_, err = db.GetUserByEmail("test@example.com")
|
|
||||||
assert.Error(t, err, "DB operations should fail after close")
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tests for error handling in GetUserByEmail
|
|
||||||
func TestGetUserByEmailError(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Test the error case with a non-existent email
|
|
||||||
user, err := db.GetUserByEmail("nonexistent@example.com")
|
|
||||||
|
|
||||||
// Verify expectations
|
|
||||||
assert.Error(t, err, "Should return an error when user is not found")
|
|
||||||
assert.Nil(t, user, "User should be nil when an error occurs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for error handling in GetUserByID
|
|
||||||
func TestGetUserByIDError(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Test the error case with a non-existent ID
|
|
||||||
user, err := db.GetUserByID(9999)
|
|
||||||
|
|
||||||
// Verify expectations
|
|
||||||
assert.Error(t, err, "Should return an error when user is not found")
|
|
||||||
assert.Nil(t, user, "User should be nil when an error occurs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for error handling in GetPasswordResetToken
|
|
||||||
func TestGetPasswordResetTokenError(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Test the error case with an invalid token
|
|
||||||
token, err := db.GetPasswordResetToken("invalid-token")
|
|
||||||
|
|
||||||
// Verify expectations
|
|
||||||
assert.Error(t, err, "Should return an error when token is not found")
|
|
||||||
assert.Nil(t, token, "Token should be nil when an error occurs")
|
|
||||||
|
|
||||||
// Test with an expired token
|
|
||||||
testUser := &User{
|
|
||||||
Email: "expired-token@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err = db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create an expired token (expired 1 hour ago)
|
|
||||||
expiredToken := &PasswordResetToken{
|
|
||||||
UserID: testUser.ID,
|
|
||||||
Token: "expired-token",
|
|
||||||
ExpiresAt: time.Now().Add(-1 * time.Hour),
|
|
||||||
}
|
|
||||||
err = db.CreatePasswordResetToken(expiredToken)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Try to get the expired token
|
|
||||||
retrievedToken, err := db.GetPasswordResetToken("expired-token")
|
|
||||||
assert.Error(t, err, "Should return an error for expired token")
|
|
||||||
assert.Nil(t, retrievedToken, "Token should be nil for expired token")
|
|
||||||
|
|
||||||
// Create a used token
|
|
||||||
usedToken := &PasswordResetToken{
|
|
||||||
UserID: testUser.ID,
|
|
||||||
Token: "used-token",
|
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
|
||||||
Used: BoolPtr(true),
|
|
||||||
}
|
|
||||||
err = db.CreatePasswordResetToken(usedToken)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Try to get the used token
|
|
||||||
retrievedToken, err = db.GetPasswordResetToken("used-token")
|
|
||||||
assert.Error(t, err, "Should return an error for used token")
|
|
||||||
assert.Nil(t, retrievedToken, "Token should be nil for used token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for error handling in DeleteTransferConfig
|
|
||||||
func TestDeleteTransferConfigError(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Test deleting a non-existent config
|
|
||||||
err := db.DeleteTransferConfig(9999)
|
|
||||||
|
|
||||||
// Verify expectations - should not return an error even if the record doesn't exist
|
|
||||||
assert.NoError(t, err, "Should not return an error when deleting non-existent config")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for error handling in DeleteJob
|
|
||||||
func TestDeleteJobError(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Test deleting a non-existent job
|
|
||||||
err := db.DeleteJob(9999)
|
|
||||||
|
|
||||||
// Verify expectations - should not return an error even if the record doesn't exist
|
|
||||||
assert.NoError(t, err, "Should not return an error when deleting non-existent job")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for error handling in GetFileMetadataByHash
|
|
||||||
func TestGetFileMetadataByHashError(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Test the error case with an invalid hash
|
|
||||||
metadata, err := db.GetFileMetadataByHash("invalid-hash")
|
|
||||||
|
|
||||||
// Verify expectations
|
|
||||||
assert.Error(t, err, "Should return an error when metadata is not found")
|
|
||||||
assert.Nil(t, metadata, "Metadata should be nil when an error occurs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for error handling in Initialize
|
|
||||||
func TestInitializeErrors(t *testing.T) {
|
|
||||||
// Test with a path that is a directory, not a file
|
|
||||||
// This should cause an error when trying to open a SQLite database
|
|
||||||
_, err := Initialize("/dev/null/cannot_be_a_db")
|
|
||||||
assert.Error(t, err, "Should return an error with invalid path")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for error handling in GenerateRcloneConfig
|
|
||||||
func TestGenerateRcloneConfigErrors(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
testUser := &User{
|
|
||||||
Email: "config-error-test@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
IsAdmin: BoolPtr(false),
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a config with invalid credentials for an SFTP connection
|
|
||||||
invalidConfig := &TransferConfig{
|
|
||||||
Name: "Invalid Config",
|
|
||||||
SourceType: "sftp", // Using SFTP with invalid host to force error
|
|
||||||
SourceHost: "nonexistent.host",
|
|
||||||
SourcePort: 22,
|
|
||||||
SourceUser: "invaliduser",
|
|
||||||
SourcePath: "/source/path",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/destination/path",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.CreateTransferConfig(invalidConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Set a non-existent RCLONE_PATH to force error
|
|
||||||
t.Setenv("RCLONE_PATH", "/nonexistent/rclone")
|
|
||||||
|
|
||||||
// This should return an error because the rclone command doesn't exist
|
|
||||||
err = db.GenerateRcloneConfig(invalidConfig)
|
|
||||||
assert.Error(t, err, "Should return an error when rclone command fails")
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestInitializeWithNonExistentDirectory tests initialization with a directory that doesn't exist
|
|
||||||
func TestInitializeWithNonExistentDirectory(t *testing.T) {
|
|
||||||
// Create a temporary directory path
|
|
||||||
tempDir := filepath.Join(os.TempDir(), "gomft_test_nonexistent")
|
|
||||||
|
|
||||||
// Make sure the directory doesn't exist
|
|
||||||
_ = os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// Create a path inside the non-existent directory
|
|
||||||
dbPath := filepath.Join(tempDir, "test.db")
|
|
||||||
|
|
||||||
// Initialize the database - this should create the directory
|
|
||||||
db, err := Initialize(dbPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, db)
|
|
||||||
|
|
||||||
// Verify the directory was created
|
|
||||||
_, err = os.Stat(tempDir)
|
|
||||||
assert.NoError(t, err, "Directory should be created")
|
|
||||||
|
|
||||||
// Close and clean up
|
|
||||||
err = db.Close()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
_ = os.RemoveAll(tempDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInitializeWithInvalidDBPath tests initialization with an invalid DB path
|
|
||||||
func TestInitializeWithInvalidDBPath(t *testing.T) {
|
|
||||||
// Create a file path that can't be a SQLite database
|
|
||||||
invalidPath := "/dev/null/invalid.db"
|
|
||||||
|
|
||||||
// Attempt to initialize with an invalid path
|
|
||||||
db, err := Initialize(invalidPath)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Nil(t, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInitializeWithExistingDB tests initialization with an existing database
|
|
||||||
func TestInitializeWithExistingDB(t *testing.T) {
|
|
||||||
// Create a temporary directory
|
|
||||||
tempDir, err := os.MkdirTemp("", "gomft_test_existing")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// Create a database path
|
|
||||||
dbPath := filepath.Join(tempDir, "existing.db")
|
|
||||||
|
|
||||||
// Initialize the database for the first time
|
|
||||||
db1, err := Initialize(dbPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, db1)
|
|
||||||
|
|
||||||
// Create a test user to verify the database works
|
|
||||||
user := &User{
|
|
||||||
Email: "test@example.com",
|
|
||||||
PasswordHash: "hash",
|
|
||||||
IsAdmin: BoolPtr(true),
|
|
||||||
}
|
|
||||||
err = db1.CreateUser(user)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotZero(t, user.ID)
|
|
||||||
|
|
||||||
// Close the first database connection
|
|
||||||
err = db1.Close()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Initialize the database again with the same path
|
|
||||||
db2, err := Initialize(dbPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, db2)
|
|
||||||
|
|
||||||
// Verify we can read the user that was created earlier
|
|
||||||
retrievedUser, err := db2.GetUserByEmail("test@example.com")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, user.ID, retrievedUser.ID)
|
|
||||||
|
|
||||||
// Close the second database connection
|
|
||||||
err = db2.Close()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCloseMultipleTimes tests closing the database multiple times
|
|
||||||
func TestCloseMultipleTimes(t *testing.T) {
|
|
||||||
// Create a temporary directory
|
|
||||||
tempDir, err := os.MkdirTemp("", "gomft_test_close")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
defer os.RemoveAll(tempDir)
|
|
||||||
|
|
||||||
// Create a database path
|
|
||||||
dbPath := filepath.Join(tempDir, "close.db")
|
|
||||||
|
|
||||||
// Initialize the database
|
|
||||||
db, err := Initialize(dbPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, db)
|
|
||||||
|
|
||||||
// Close the database
|
|
||||||
err = db.Close()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Trying to close it again - for some DB drivers this might cause an error
|
|
||||||
// but SQLite in-memory seems to handle this gracefully
|
|
||||||
err = db.Close()
|
|
||||||
// We won't assert error here since it depends on the driver
|
|
||||||
t.Logf("Second close resulted in: %v", err)
|
|
||||||
|
|
||||||
// Instead, let's test that DB operations fail after close
|
|
||||||
_, err = db.GetUserByEmail("test@example.com")
|
|
||||||
assert.Error(t, err, "DB operations should fail after close")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInitializeWithMigrationFailure tests when AutoMigrate fails
|
|
||||||
func TestInitializeWithMigrationFailure(t *testing.T) {
|
|
||||||
// We can't easily cause a migration failure with SQLite
|
|
||||||
// but we can skip this test and document that it's hard to test
|
|
||||||
t.Skip("Testing migration failure is difficult with SQLite")
|
|
||||||
|
|
||||||
// In a real-world scenario, this might happen if:
|
|
||||||
// 1. The schema changed significantly between versions
|
|
||||||
// 2. The database is corrupted
|
|
||||||
// 3. There are permission issues
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-gormigrate/gormigrate/v2"
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -10,6 +15,60 @@ func UpdateGDriveType() *gormigrate.Migration {
|
|||||||
return &gormigrate.Migration{
|
return &gormigrate.Migration{
|
||||||
ID: "002_update_gdrive_type",
|
ID: "002_update_gdrive_type",
|
||||||
Migrate: func(tx *gorm.DB) error {
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
// Update source_type
|
// Update source_type
|
||||||
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
|
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddAuditLogs creates a migration for adding audit logs and user roles tables
|
||||||
|
func AddAuditLogs() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "004_add_audit_logs",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create roles table first
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
permissions TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit_logs table
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMP NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT idx_audit_logs_action CHECK (action IS NOT NULL),
|
||||||
|
CONSTRAINT idx_audit_logs_entity_type CHECK (entity_type IS NOT NULL),
|
||||||
|
CONSTRAINT idx_audit_logs_entity_id CHECK (entity_id IS NOT NULL),
|
||||||
|
CONSTRAINT idx_audit_logs_user_id CHECK (user_id IS NOT NULL),
|
||||||
|
CONSTRAINT idx_audit_logs_timestamp CHECK (timestamp IS NOT NULL)
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user_roles table for many-to-many relationship
|
||||||
|
return tx.Exec(`
|
||||||
|
CREATE TABLE user_roles (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
role_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, role_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("DROP TABLE IF EXISTS user_roles").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("DROP TABLE IF EXISTS audit_logs").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec("DROP TABLE IF EXISTS roles").Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddDefaultRoles creates a migration for adding default system roles
|
||||||
|
func AddDefaultRoles() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "005_add_default_roles",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define default roles with their permissions
|
||||||
|
defaultRoles := []struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Permissions []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "admin",
|
||||||
|
Description: "Full system administrator access",
|
||||||
|
Permissions: []string{
|
||||||
|
"users.view", "users.create", "users.edit", "users.delete",
|
||||||
|
"roles.view", "roles.create", "roles.edit", "roles.delete",
|
||||||
|
"configs.view", "configs.create", "configs.edit", "configs.delete",
|
||||||
|
"jobs.view", "jobs.create", "jobs.edit", "jobs.delete", "jobs.run",
|
||||||
|
"audit.view", "audit.export",
|
||||||
|
"system.settings", "system.backup", "system.restore",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "system",
|
||||||
|
Description: "System-level access for automated processes",
|
||||||
|
Permissions: []string{
|
||||||
|
"jobs.view", "jobs.create", "jobs.edit", "jobs.delete", "jobs.run",
|
||||||
|
"configs.view", "configs.create", "configs.edit",
|
||||||
|
"audit.view",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "user",
|
||||||
|
Description: "Standard user access",
|
||||||
|
Permissions: []string{
|
||||||
|
"jobs.view", "jobs.run",
|
||||||
|
"configs.view",
|
||||||
|
"audit.view",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create each role
|
||||||
|
for _, role := range defaultRoles {
|
||||||
|
// Convert permissions to JSON string
|
||||||
|
permsJSON, err := json.Marshal(role.Permissions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if role already exists
|
||||||
|
var count int64
|
||||||
|
if err := tx.Table("roles").Where("name = ?", role.Name).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if role already exists
|
||||||
|
if count > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the role
|
||||||
|
now := time.Now()
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO roles (name, description, permissions, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, role.Name, role.Description, string(permsJSON), now, now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log entry with system user ID (1)
|
||||||
|
details, err := json.Marshal(map[string]interface{}{
|
||||||
|
"role_name": role.Name,
|
||||||
|
"permissions": role.Permissions,
|
||||||
|
"description": role.Description,
|
||||||
|
"system_created": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||||
|
SELECT 'create_role', 'role', id, 1, ?, ?, ?, ?
|
||||||
|
FROM roles WHERE name = ?
|
||||||
|
`, string(details), now, now, now, role.Name).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign admin role to admin user (ID 1)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Check if admin user exists
|
||||||
|
var adminUserCount int64
|
||||||
|
if err := tx.Table("users").Where("id = ?", 1).Count(&adminUserCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if adminUserCount == 0 {
|
||||||
|
return nil // Skip if admin user doesn't exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin role ID
|
||||||
|
var adminRoleID uint
|
||||||
|
if err := tx.Table("roles").Select("id").Where("name = ?", "admin").Row().Scan(&adminRoleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if role is already assigned
|
||||||
|
var assignmentCount int64
|
||||||
|
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", 1, adminRoleID).Count(&assignmentCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if assignmentCount > 0 {
|
||||||
|
return nil // Skip if already assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign admin role to admin user
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO user_roles (user_id, role_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, 1, adminRoleID, now, now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log for role assignment
|
||||||
|
details, err := json.Marshal(map[string]interface{}{
|
||||||
|
"role_name": "admin",
|
||||||
|
"user_id": 1,
|
||||||
|
"system_created": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Exec(`
|
||||||
|
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||||
|
VALUES ('assign_role', 'role', ?, 1, ?, ?, ?, ?)
|
||||||
|
`, adminRoleID, string(details), now, now, now).Error
|
||||||
|
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
// Don't delete the roles on rollback as they might be in use
|
||||||
|
// Instead, log that we're skipping role deletion
|
||||||
|
now := time.Now()
|
||||||
|
details, err := json.Marshal(map[string]interface{}{
|
||||||
|
"message": "Skipped deletion of default roles for safety",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Exec(`
|
||||||
|
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||||
|
VALUES ('migration_rollback', 'roles', 0, 1, ?, ?, ?, ?)
|
||||||
|
`, string(details), now, now, now).Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddTimestampsToJobHistories creates a migration for adding timestamp columns to job_histories table
|
||||||
|
func AddTimestampsToJobHistories() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "006_add_timestamps_to_job_histories",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add created_at column
|
||||||
|
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN created_at DATETIME").Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to add created_at column: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add updated_at column
|
||||||
|
if err := tx.Exec("ALTER TABLE job_histories ADD COLUMN updated_at DATETIME").Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to add updated_at column: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default values for existing records
|
||||||
|
now := time.Now()
|
||||||
|
if err := tx.Exec("UPDATE job_histories SET created_at = ?, updated_at = ? WHERE created_at IS NULL", now, now).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to update existing records: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log entry for the migration
|
||||||
|
details, err := json.Marshal(map[string]interface{}{
|
||||||
|
"columns_added": []string{"created_at", "updated_at"},
|
||||||
|
"table": "job_histories",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Exec(`
|
||||||
|
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||||
|
VALUES ('schema_update', 'job_histories', 0, 1, ?, ?, ?, ?)
|
||||||
|
`, string(details), now, now, now).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
// In SQLite, we can't drop columns directly, so we'd need to:
|
||||||
|
// 1. Create a new table without the columns
|
||||||
|
// 2. Copy data from the old table
|
||||||
|
// 3. Drop the old table
|
||||||
|
// 4. Rename the new table
|
||||||
|
//
|
||||||
|
// However, keeping the timestamp columns is generally harmless,
|
||||||
|
// so we'll just log a message instead of performing a risky rollback
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
details, err := json.Marshal(map[string]interface{}{
|
||||||
|
"message": "Skipped removal of timestamp columns from job_histories due to SQLite limitations",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Exec(`
|
||||||
|
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||||
|
VALUES ('migration_rollback', 'job_histories', 0, 1, ?, ?, ?, ?)
|
||||||
|
`, string(details), now, now, now).Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddNotificationServices creates a migration for adding the notification services table
|
||||||
|
func AddNotificationServices() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "007_add_notification_services",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification_services table
|
||||||
|
type NotificationService struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Name string `gorm:"not null"`
|
||||||
|
Type string `gorm:"not null"` // email, slack, webhook
|
||||||
|
IsEnabled bool `gorm:"default:true"`
|
||||||
|
ConfigJSON string `gorm:"column:config"`
|
||||||
|
Description string
|
||||||
|
EventTriggers string `gorm:"column:event_triggers;default:'[]'"`
|
||||||
|
PayloadTemplate string `gorm:"column:payload_template"`
|
||||||
|
SecretKey string `gorm:"column:secret_key"`
|
||||||
|
RetryPolicy string `gorm:"column:retry_policy;default:'simple'"`
|
||||||
|
LastUsed time.Time `gorm:"column:last_used"`
|
||||||
|
SuccessCount int `gorm:"column:success_count;default:0"`
|
||||||
|
FailureCount int `gorm:"column:failure_count;default:0"`
|
||||||
|
CreatedBy uint
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.AutoMigrate(&NotificationService{}); err != nil {
|
||||||
|
return fmt.Errorf("failed to create notification_services table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log entry
|
||||||
|
now := time.Now()
|
||||||
|
details, err := json.Marshal(map[string]interface{}{
|
||||||
|
"table_name": "notification_services",
|
||||||
|
"system_created": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Exec(`
|
||||||
|
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||||
|
VALUES ('create_table', 'schema', 0, 1, ?, ?, ?, ?)
|
||||||
|
`, string(details), now, now, now).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
// Log rollback operation
|
||||||
|
now := time.Time{}
|
||||||
|
details, err := json.Marshal(map[string]interface{}{
|
||||||
|
"table_name": "notification_services",
|
||||||
|
"message": "Dropping notification_services table",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO audit_logs (action, entity_type, entity_id, user_id, details, created_at, updated_at, timestamp)
|
||||||
|
VALUES ('drop_table', 'schema', 0, 1, ?, ?, ?, ?)
|
||||||
|
`, string(details), now, now, now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the table
|
||||||
|
return tx.Migrator().DropTable("notification_services")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddUserNotifications adds the user_notifications table
|
||||||
|
func AddUserNotifications() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "008_add_user_notifications",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
// Create the user_notifications table
|
||||||
|
err := tx.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
link TEXT NOT NULL,
|
||||||
|
job_id INTEGER,
|
||||||
|
job_run_id INTEGER,
|
||||||
|
config_id INTEGER,
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an index on user_id for faster lookups
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notifications_user_id ON user_notifications(user_id)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an index on created_at for faster sorting
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notifications_created_at ON user_notifications(created_at)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an index on is_read for faster filtering of unread notifications
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_notifications_is_read ON user_notifications(is_read)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(`DROP TABLE IF EXISTS user_notifications`).Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddRcloneTables adds tables for rclone commands and their flags
|
||||||
|
func AddRcloneTables() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "009_add_rclone_tables",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the rclone_commands table
|
||||||
|
err := tx.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rclone_commands (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
is_advanced BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an index on name for faster lookups
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_rclone_commands_name ON rclone_commands(name)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an index on category for faster filtering
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rclone_commands_category ON rclone_commands(category)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the rclone_command_flags table
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rclone_command_flags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
command_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
short_name TEXT,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
data_type TEXT NOT NULL,
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
default_value TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (command_id) REFERENCES rclone_commands(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on command_id for faster lookups
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rclone_command_flags_command_id ON rclone_command_flags(command_id)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index on flag name for faster searches
|
||||||
|
err = tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rclone_command_flags_name ON rclone_command_flags(name)
|
||||||
|
`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the tables with default rclone commands and flags
|
||||||
|
return populateRcloneTablesWithDefaults(tx)
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
// Drop the flags table first due to foreign key constraints
|
||||||
|
err := tx.Exec(`DROP TABLE IF EXISTS rclone_command_flags`).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then drop the commands table
|
||||||
|
return tx.Exec(`DROP TABLE IF EXISTS rclone_commands`).Error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to populate rclone tables with default data
|
||||||
|
func populateRcloneTablesWithDefaults(tx *gorm.DB) error {
|
||||||
|
// Define base commands
|
||||||
|
commands := []struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Category string
|
||||||
|
IsAdvanced bool
|
||||||
|
}{
|
||||||
|
// Core commands
|
||||||
|
{"copy", "Copy files from source to dest, skipping already copied", "sync", false},
|
||||||
|
{"sync", "Make source and dest identical, modifying destination only", "sync", false},
|
||||||
|
{"bisync", "Bidirectional synchronization between two paths", "sync", false},
|
||||||
|
{"move", "Move files from source to dest", "sync", false},
|
||||||
|
{"delete", "Remove the contents of path", "sync", false},
|
||||||
|
{"purge", "Remove the path and all of its contents", "sync", false},
|
||||||
|
{"mkdir", "Make the path if it doesn't already exist", "sync", false},
|
||||||
|
{"rmdir", "Remove the path", "sync", false},
|
||||||
|
{"rmdirs", "Remove any empty directories under the path", "sync", false},
|
||||||
|
{"check", "Check if the files in the source and destination match", "sync", false},
|
||||||
|
{"ls", "List all the objects in the path with size and path", "listing", false},
|
||||||
|
{"lsd", "List all directories/containers/buckets in the path", "listing", false},
|
||||||
|
{"lsl", "List all the objects in the path with size, modification time and path", "listing", false},
|
||||||
|
{"lsf", "List the objects in the path (obey formatting parameters)", "listing", false},
|
||||||
|
{"lsjson", "List the objects in the path in JSON format", "listing", false},
|
||||||
|
{"md5sum", "Produce an md5sum file for all the objects in the path", "hash", false},
|
||||||
|
{"sha1sum", "Produce a sha1sum file for all the objects in the path", "hash", false},
|
||||||
|
{"size", "Return the total size and number of objects in remote:path", "info", false},
|
||||||
|
{"version", "Show the version number", "info", false},
|
||||||
|
{"cleanup", "Clean up the remote if possible", "maintenance", false},
|
||||||
|
{"dedupe", "Interactively find duplicate files and delete/rename them", "maintenance", false},
|
||||||
|
{"copyto", "Copy files from source to dest, skipping already copied", "sync", false},
|
||||||
|
{"moveto", "Move file or directory from source to dest", "sync", false},
|
||||||
|
{"listremotes", "List all the remotes in the config file", "config", false},
|
||||||
|
{"obscure", "Obscure password for use in the rclone.conf", "config", false},
|
||||||
|
{"cryptcheck", "Check the integrity of an encrypted remote", "crypto", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert commands
|
||||||
|
for _, cmd := range commands {
|
||||||
|
err := tx.Exec(`
|
||||||
|
INSERT INTO rclone_commands
|
||||||
|
(name, description, category, is_advanced, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, cmd.Name, cmd.Description, cmd.Category, cmd.IsAdvanced, time.Now()).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert command %s: %v", cmd.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define all flags
|
||||||
|
allFlags := []struct {
|
||||||
|
CommandName string
|
||||||
|
Name string
|
||||||
|
ShortName string
|
||||||
|
Description string
|
||||||
|
DataType string
|
||||||
|
IsRequired bool
|
||||||
|
DefaultValue string
|
||||||
|
}{
|
||||||
|
// Global flags - apply to most commands
|
||||||
|
{"global", "transfers", "n", "Number of file transfers to run in parallel", "int", false, "4"},
|
||||||
|
{"global", "checkers", "", "Number of checkers to run in parallel", "int", false, "8"},
|
||||||
|
{"global", "log-level", "", "Log level DEBUG|INFO|NOTICE|ERROR", "string", false, "NOTICE"},
|
||||||
|
{"global", "stats", "", "Interval between logging stats, e.g 500ms, 60s, 5m", "string", false, "1m0s"},
|
||||||
|
{"global", "stats-file-name-length", "", "Max file name length in stats", "int", false, "45"},
|
||||||
|
{"global", "stats-one-line", "", "Make the stats fit on one line", "bool", false, "false"},
|
||||||
|
{"global", "progress", "p", "Show progress during transfer", "bool", false, "false"},
|
||||||
|
{"global", "verbose", "v", "Show verbose output", "bool", false, "false"},
|
||||||
|
{"global", "quiet", "q", "Print as little stuff as possible", "bool", false, "false"},
|
||||||
|
{"global", "retries", "", "Retry operations this many times if they fail", "int", false, "3"},
|
||||||
|
{"global", "retries-sleep", "", "Interval between retrying operations if they fail", "string", false, "0s"},
|
||||||
|
{"global", "timeout", "", "IO idle timeout", "string", false, "5m0s"},
|
||||||
|
{"global", "tpslimit", "", "Limit HTTP transactions per second", "float", false, "0"},
|
||||||
|
{"global", "tpslimit-burst", "", "Max burst of transactions for --tpslimit", "int", false, "1"},
|
||||||
|
{"global", "size-only", "", "Skip based on size only, not mod-time or checksum", "bool", false, "false"},
|
||||||
|
{"global", "ignore-checksum", "", "Skip post copy check of checksums", "bool", false, "false"},
|
||||||
|
{"global", "ignore-existing", "", "Skip all files that exist on destination", "bool", false, "false"},
|
||||||
|
{"global", "ignore-size", "", "Skip size checks to calculate if file changed", "bool", false, "false"},
|
||||||
|
{"global", "ignore-case-sync", "", "Ignore case when synchronizing", "bool", false, "false"},
|
||||||
|
{"global", "no-update-modtime", "", "Don't update destination mod-time if files identical", "bool", false, "false"},
|
||||||
|
{"global", "no-check-certificate", "", "Do not verify server SSL certificates", "bool", false, "false"},
|
||||||
|
{"global", "ask-password", "", "Allow prompt for password for encrypted config", "bool", false, "false"},
|
||||||
|
{"global", "dump", "", "List of items to dump from: headers,bodies,requests,responses,auth,filters,goroutines,openfiles", "string", false, ""},
|
||||||
|
{"global", "metadata", "M", "Preserve metadata when copying objects", "bool", false, "false"},
|
||||||
|
{"global", "metadata-set", "", "Add metadata key=value when uploading", "string", false, ""},
|
||||||
|
|
||||||
|
// copy command specific flags
|
||||||
|
{"copy", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
|
||||||
|
{"copy", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
|
||||||
|
{"copy", "cutoff-mode", "", "Mode to stop transfers when reaching the cutoff threshold", "string", false, "hard"},
|
||||||
|
{"copy", "max-transfer", "", "Maximum size of data to transfer", "string", false, "off"},
|
||||||
|
{"copy", "max-backlog", "", "Maximum size of upload or download backlog", "int", false, "10000"},
|
||||||
|
{"copy", "track-renames", "", "Track file renames during sync", "bool", false, "false"},
|
||||||
|
{"copy", "track-renames-strategy", "", "Strategies to detect renames (hash|modtime|leaf)", "string", false, "hash"},
|
||||||
|
|
||||||
|
// sync command specific flags
|
||||||
|
{"sync", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
|
||||||
|
{"sync", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
|
||||||
|
{"sync", "backup-dir", "", "Make backups into this directory", "string", false, ""},
|
||||||
|
{"sync", "suffix", "", "Suffix to add to changed files", "string", false, ""},
|
||||||
|
{"sync", "delete-before", "", "Delete before transferring", "bool", false, "false"},
|
||||||
|
{"sync", "delete-during", "", "Delete during transferring", "bool", false, "false"},
|
||||||
|
{"sync", "delete-after", "", "Delete after transferring", "bool", false, "false"},
|
||||||
|
{"sync", "track-renames", "", "Track file renames during sync", "bool", false, "false"},
|
||||||
|
{"sync", "track-renames-strategy", "", "Strategies to detect renames (hash|modtime|leaf)", "string", false, "hash"},
|
||||||
|
|
||||||
|
// bisync command specific flags
|
||||||
|
{"bisync", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
|
||||||
|
{"bisync", "resync", "", "Performs the resync run", "bool", false, "false"},
|
||||||
|
{"bisync", "check-access", "", "Ensure destination has write access", "bool", false, "true"},
|
||||||
|
{"bisync", "conflict-resolve", "", "Automatically resolve conflicts (newer|larger|older|smaller)", "string", false, ""},
|
||||||
|
{"bisync", "max-delete", "", "Safety check on maximum files to delete", "int", false, "10"},
|
||||||
|
|
||||||
|
// move command specific flags
|
||||||
|
{"move", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
|
||||||
|
{"move", "create-empty-src-dirs", "", "Create empty source dirs on destination", "bool", false, "false"},
|
||||||
|
{"move", "delete-empty-src-dirs", "", "Delete empty source dirs after move", "bool", false, "false"},
|
||||||
|
|
||||||
|
// ls/listing related flags
|
||||||
|
{"ls", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
|
||||||
|
{"ls", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
|
||||||
|
{"ls", "format", "", "Format for the output", "string", false, ""},
|
||||||
|
{"ls", "absolute", "", "Put a leading / in front of path names", "bool", false, "false"},
|
||||||
|
|
||||||
|
{"lsl", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
|
||||||
|
{"lsl", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
|
||||||
|
|
||||||
|
{"lsd", "max-depth", "", "Maximum depth to show in the listing", "int", false, ""},
|
||||||
|
{"lsd", "dir-sort", "", "Directory sorting (alphabetical|size|time)", "string", false, "alphabetical"},
|
||||||
|
|
||||||
|
{"lsf", "format", "", "Format for the output", "string", false, ""},
|
||||||
|
{"lsf", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
|
||||||
|
{"lsf", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
|
||||||
|
|
||||||
|
{"lsjson", "recursive", "R", "Recurse into the listing", "bool", false, "false"},
|
||||||
|
{"lsjson", "max-depth", "", "Maximum depth to recursively list", "int", false, ""},
|
||||||
|
{"lsjson", "files-only", "", "Show only files, not directories", "bool", false, "false"},
|
||||||
|
{"lsjson", "encrypted", "", "Show the encrypted names", "bool", false, "false"},
|
||||||
|
{"lsjson", "stat", "", "Show status of objects", "bool", false, "false"},
|
||||||
|
|
||||||
|
// Other specialized command flags
|
||||||
|
{"cryptcheck", "one-way", "", "Check one way only, source files must exist on destination", "bool", false, "false"},
|
||||||
|
|
||||||
|
{"cleanup", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
|
||||||
|
|
||||||
|
{"dedupe", "dry-run", "", "Do a trial run with no permanent changes", "bool", false, "false"},
|
||||||
|
{"dedupe", "mode", "", "Dedupe mode interactive|skip|first|newest|oldest|largest|smallest|rename", "string", false, "interactive"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert flags with associated command IDs
|
||||||
|
for _, flag := range allFlags {
|
||||||
|
// For global flags, add them to all commands
|
||||||
|
if flag.CommandName == "global" {
|
||||||
|
// Get all command IDs
|
||||||
|
var commandIDs []int64
|
||||||
|
err := tx.Raw(`SELECT id FROM rclone_commands`).Scan(&commandIDs).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get all command IDs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add global flag to each command
|
||||||
|
for _, cmdID := range commandIDs {
|
||||||
|
err = tx.Exec(`
|
||||||
|
INSERT INTO rclone_command_flags
|
||||||
|
(command_id, name, short_name, description, data_type, is_required, default_value, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, cmdID, flag.Name, flag.ShortName, flag.Description, flag.DataType, flag.IsRequired, flag.DefaultValue, time.Now()).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert global flag %s for command ID %d: %v", flag.Name, cmdID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add command-specific flag
|
||||||
|
var commandID int64
|
||||||
|
err := tx.Raw(`SELECT id FROM rclone_commands WHERE name = ?`, flag.CommandName).Scan(&commandID).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find command %s: %v", flag.CommandName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Exec(`
|
||||||
|
INSERT INTO rclone_command_flags
|
||||||
|
(command_id, name, short_name, description, data_type, is_required, default_value, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, commandID, flag.Name, flag.ShortName, flag.Description, flag.DataType, flag.IsRequired, flag.DefaultValue, time.Now()).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert flag %s for command %s: %v", flag.Name, flag.CommandName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddRcloneCommandToConfig adds rclone command fields to the transfer_configs table
|
||||||
|
func AddRcloneCommandToConfig() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "010_add_rclone_command_to_config",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the command_id and command_flags columns to the transfer_configs table
|
||||||
|
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_id INTEGER DEFAULT NULL REFERENCES rclone_commands(id)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flags TEXT DEFAULT NULL`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`ALTER TABLE transfer_configs ADD COLUMN command_flag_values TEXT DEFAULT NULL`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
// Remove the columns in reverse order
|
||||||
|
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flags`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_id`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`ALTER TABLE transfer_configs DROP COLUMN command_flag_values`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddAuthProviders adds tables for external authentication providers
|
||||||
|
func AddAuthProviders() *gormigrate.Migration {
|
||||||
|
return &gormigrate.Migration{
|
||||||
|
ID: "011_add_auth_providers",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
// Check if any tables exist (indicating an existing database)
|
||||||
|
var count int64
|
||||||
|
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If tables exist, create a backup
|
||||||
|
if count > 0 {
|
||||||
|
// Get the database path
|
||||||
|
sqlDB, err := tx.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var seq int
|
||||||
|
var name, dbPath string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to get database path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backup directory from environment variable or use default
|
||||||
|
backupDir := os.Getenv("BACKUP_DIR")
|
||||||
|
if backupDir == "" {
|
||||||
|
backupDir = "/app/backups" // Default Docker path
|
||||||
|
// Check if we're not in Docker
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
backupDir = "backups" // Fallback to local directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup file with timestamp in the backup directory
|
||||||
|
dbFileName := filepath.Base(dbPath)
|
||||||
|
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||||
|
backupFile := filepath.Join(backupDir, backupFileName)
|
||||||
|
|
||||||
|
// Read original database
|
||||||
|
data, err := os.ReadFile(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write backup
|
||||||
|
if err := os.WriteFile(backupFile, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write database backup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Created database backup at %s\n", backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create auth_providers table
|
||||||
|
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS auth_providers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
description TEXT,
|
||||||
|
provider_url TEXT,
|
||||||
|
icon_url TEXT,
|
||||||
|
client_id VARCHAR(255),
|
||||||
|
client_secret VARCHAR(255),
|
||||||
|
redirect_url TEXT,
|
||||||
|
scopes TEXT,
|
||||||
|
attribute_mapping TEXT,
|
||||||
|
config TEXT,
|
||||||
|
successful_logins INTEGER DEFAULT 0,
|
||||||
|
last_used DATETIME,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create external_user_identities table
|
||||||
|
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS external_user_identities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
provider_id INTEGER NOT NULL,
|
||||||
|
provider_type VARCHAR(50) NOT NULL,
|
||||||
|
external_id VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
username VARCHAR(255),
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
groups TEXT,
|
||||||
|
last_login DATETIME,
|
||||||
|
provider_data TEXT,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (provider_id) REFERENCES auth_providers(id) ON DELETE CASCADE
|
||||||
|
)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique index on provider_id and external_id
|
||||||
|
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_external_user_identities_provider_external
|
||||||
|
ON external_user_identities(provider_id, external_id)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec("DROP TABLE IF EXISTS external_user_identities").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec("DROP TABLE IF EXISTS auth_providers").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,24 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitMigrations initializes the migrations
|
var migrations []*gormigrate.Migration
|
||||||
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
|
||||||
migrations := []*gormigrate.Migration{
|
// GetMigrations returns all migrations
|
||||||
InitialSchema(),
|
func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
||||||
UpdateGDriveType(),
|
// Add all migrations in order
|
||||||
Add2FA(),
|
migrations = append(migrations,
|
||||||
}
|
InitialSchema(), // 001
|
||||||
|
UpdateGDriveType(), // 002
|
||||||
|
Add2FA(), // 003
|
||||||
|
AddAuditLogs(), // 004
|
||||||
|
AddDefaultRoles(), // 005
|
||||||
|
AddTimestampsToJobHistories(), // 006
|
||||||
|
AddNotificationServices(), // 007
|
||||||
|
AddUserNotifications(), // 008
|
||||||
|
AddRcloneTables(), // 009
|
||||||
|
AddRcloneCommandToConfig(), // 010
|
||||||
|
AddAuthProviders(), // 011
|
||||||
|
)
|
||||||
|
|
||||||
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
return gormigrate.New(db, gormigrate.DefaultOptions, migrations)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationService represents a notification service configuration
|
||||||
|
type NotificationService struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"not null"`
|
||||||
|
Type string `json:"type" gorm:"not null"` // email, webhook
|
||||||
|
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
|
||||||
|
Config map[string]string `json:"config" gorm:"-"`
|
||||||
|
ConfigJSON string `json:"-" gorm:"column:config"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
EventTriggers []string `json:"event_triggers" gorm:"-"`
|
||||||
|
EventTriggersJSON string `json:"-" gorm:"column:event_triggers;default:'[]'"`
|
||||||
|
PayloadTemplate string `json:"payload_template" gorm:"column:payload_template"`
|
||||||
|
SecretKey string `json:"secret_key" gorm:"column:secret_key"`
|
||||||
|
RetryPolicy string `json:"retry_policy" gorm:"column:retry_policy;default:'simple'"`
|
||||||
|
LastUsed time.Time `json:"last_used" gorm:"column:last_used"`
|
||||||
|
SuccessCount int `json:"success_count" gorm:"column:success_count;default:0"`
|
||||||
|
FailureCount int `json:"failure_count" gorm:"column:failure_count;default:0"`
|
||||||
|
CreatedBy uint `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeSave converts Config map and EventTriggers to JSON strings for storage
|
||||||
|
func (n *NotificationService) BeforeSave(tx *gorm.DB) error {
|
||||||
|
configJSON, err := json.Marshal(n.Config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n.ConfigJSON = string(configJSON)
|
||||||
|
|
||||||
|
eventsJSON, err := json.Marshal(n.EventTriggers)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n.EventTriggersJSON = string(eventsJSON)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AfterFind converts JSON strings back to Config map and EventTriggers
|
||||||
|
func (n *NotificationService) AfterFind(tx *gorm.DB) error {
|
||||||
|
if n.ConfigJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(n.ConfigJSON), &n.Config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.EventTriggersJSON != "" {
|
||||||
|
if err := json.Unmarshal([]byte(n.EventTriggersJSON), &n.EventTriggers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationServices returns notification services, filtered by enabled status if specified
|
||||||
|
func (db *DB) GetNotificationServices(onlyEnabled bool) ([]NotificationService, error) {
|
||||||
|
var services []NotificationService
|
||||||
|
query := db.DB
|
||||||
|
|
||||||
|
if onlyEnabled {
|
||||||
|
query = query.Where("is_enabled = ?", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Find(&services).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return services, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationService returns a notification service by ID
|
||||||
|
func (db *DB) GetNotificationService(id uint) (*NotificationService, error) {
|
||||||
|
var service NotificationService
|
||||||
|
if err := db.First(&service, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateNotificationService creates a new notification service
|
||||||
|
func (db *DB) CreateNotificationService(service *NotificationService) error {
|
||||||
|
return db.Create(service).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotificationService updates an existing notification service
|
||||||
|
func (db *DB) UpdateNotificationService(service *NotificationService) error {
|
||||||
|
return db.Save(service).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteNotificationService deletes a notification service by ID
|
||||||
|
func (db *DB) DeleteNotificationService(id uint) error {
|
||||||
|
return db.Delete(&NotificationService{}, id).Error
|
||||||
|
}
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGetConfigRclonePathWithEnv tests the GetConfigRclonePath function with different environment variables
|
|
||||||
func TestGetConfigRclonePathWithEnv(t *testing.T) {
|
|
||||||
// Save original environment variable
|
|
||||||
originalDataDir := os.Getenv("DATA_DIR")
|
|
||||||
defer os.Setenv("DATA_DIR", originalDataDir)
|
|
||||||
|
|
||||||
// Set a custom data directory
|
|
||||||
customDir := "/tmp/custom_data_dir"
|
|
||||||
os.Setenv("DATA_DIR", customDir)
|
|
||||||
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test config
|
|
||||||
testUser := &User{
|
|
||||||
Email: "rclone-env-test@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err := db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
testConfig := &TransferConfig{
|
|
||||||
Name: "Test Config",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/source/path",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/dest/path",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateTransferConfig(testConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test GetConfigRclonePath with custom DATA_DIR
|
|
||||||
configPath := db.GetConfigRclonePath(testConfig)
|
|
||||||
assert.Equal(t,
|
|
||||||
filepath.Join(customDir, "configs", fmt.Sprintf("config_%d.conf", testConfig.ID)),
|
|
||||||
configPath,
|
|
||||||
"Should use DATA_DIR environment variable")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGenerateRcloneConfigWithoutRclone tests error handling when rclone executable is not available
|
|
||||||
func TestGenerateRcloneConfigWithoutRclone(t *testing.T) {
|
|
||||||
// Save original environment variable
|
|
||||||
originalRclonePath := os.Getenv("RCLONE_PATH")
|
|
||||||
defer os.Setenv("RCLONE_PATH", originalRclonePath)
|
|
||||||
|
|
||||||
// Set a nonexistent rclone path
|
|
||||||
os.Setenv("RCLONE_PATH", "/nonexistent/rclone")
|
|
||||||
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
testUser := &User{
|
|
||||||
Email: "rclone-missing-test@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err := db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test configs for different source types
|
|
||||||
sourceTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
|
|
||||||
|
|
||||||
for _, sourceType := range sourceTypes {
|
|
||||||
testConfig := &TransferConfig{
|
|
||||||
Name: fmt.Sprintf("Test %s Config", sourceType),
|
|
||||||
SourceType: sourceType,
|
|
||||||
SourceHost: "example.com",
|
|
||||||
SourcePort: 22,
|
|
||||||
SourceUser: "testuser",
|
|
||||||
SourcePath: "/source/path",
|
|
||||||
SourceAccessKey: "access_key",
|
|
||||||
SourceSecretKey: "secret_key",
|
|
||||||
SourceRegion: "us-east-1",
|
|
||||||
SourceEndpoint: "endpoint.example.com",
|
|
||||||
SourceClientID: "client_id",
|
|
||||||
SourceClientSecret: "client_secret",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/dest/path",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.CreateTransferConfig(testConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// This should return an error because rclone is not available
|
|
||||||
err = db.GenerateRcloneConfig(testConfig)
|
|
||||||
assert.Error(t, err, "Should return an error when rclone executable is not found for source type: %s", sourceType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test configs for different destination types
|
|
||||||
destTypes := []string{"sftp", "s3", "minio", "b2", "smb", "ftp", "webdav", "nextcloud", "onedrive", "gdrive"}
|
|
||||||
|
|
||||||
for _, destType := range destTypes {
|
|
||||||
testConfig := &TransferConfig{
|
|
||||||
Name: fmt.Sprintf("Test Dest %s Config", destType),
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/source/path",
|
|
||||||
DestinationType: destType,
|
|
||||||
DestHost: "example.com",
|
|
||||||
DestPort: 22,
|
|
||||||
DestUser: "testuser",
|
|
||||||
DestinationPath: "/dest/path",
|
|
||||||
DestAccessKey: "access_key",
|
|
||||||
DestSecretKey: "secret_key",
|
|
||||||
DestRegion: "us-east-1",
|
|
||||||
DestEndpoint: "endpoint.example.com",
|
|
||||||
DestClientID: "client_id",
|
|
||||||
DestClientSecret: "client_secret",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.CreateTransferConfig(testConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// This should return an error because rclone is not available
|
|
||||||
err = db.GenerateRcloneConfig(testConfig)
|
|
||||||
if destType != "local" {
|
|
||||||
assert.Error(t, err, "Should return an error when rclone executable is not found for dest type: %s", destType)
|
|
||||||
} else {
|
|
||||||
// Local destination type might not error since it doesn't need to call rclone
|
|
||||||
t.Logf("Local destination type might not error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGoogleDriveRcloneConfig(t *testing.T) {
|
|
||||||
// Skip if rclone not available
|
|
||||||
rclonePath := os.Getenv("RCLONE_PATH")
|
|
||||||
if rclonePath == "" {
|
|
||||||
rclonePath = "rclone" // default to PATH lookup
|
|
||||||
}
|
|
||||||
_, err := exec.Command(rclonePath, "--version").CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Skip("Skipping test as rclone is not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
testUser := &User{
|
|
||||||
Email: fmt.Sprintf("google-rclone-test-%d@example.com", time.Now().UnixNano()),
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err = db.CreateUser(testUser)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Google Drive source config
|
|
||||||
googleSourceConfig := &TransferConfig{
|
|
||||||
Name: "Google Drive Source Rclone Test",
|
|
||||||
SourceType: "gdrive",
|
|
||||||
SourcePath: "/path/in/google/drive",
|
|
||||||
SourceClientID: "source_google_client_id",
|
|
||||||
SourceClientSecret: "source_google_client_secret",
|
|
||||||
SourceTeamDrive: "source_team_drive_id",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/local/destination/path",
|
|
||||||
FilePattern: "*.pdf",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set authenticated status
|
|
||||||
authenticated := true
|
|
||||||
googleSourceConfig.GoogleDriveAuthenticated = &authenticated
|
|
||||||
|
|
||||||
// Create the config
|
|
||||||
err = db.CreateTransferConfig(googleSourceConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
err = db.GenerateRcloneConfigWithToken(googleSourceConfig, "test_token")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Generate rclone config for source
|
|
||||||
configPath := db.GetConfigRclonePath(googleSourceConfig)
|
|
||||||
|
|
||||||
// Check that the file exists
|
|
||||||
_, err = os.Stat(configPath)
|
|
||||||
assert.NoError(t, err, "Rclone config file should exist")
|
|
||||||
|
|
||||||
// Read the config file
|
|
||||||
configContent, err := os.ReadFile(configPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
content := string(configContent)
|
|
||||||
|
|
||||||
// Verify it contains Google Drive specific content
|
|
||||||
assert.Contains(t, content, "type = drive")
|
|
||||||
assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleSourceConfig.SourceClientID))
|
|
||||||
assert.Contains(t, content, "source")
|
|
||||||
assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleSourceConfig.SourceTeamDrive))
|
|
||||||
|
|
||||||
// Create Google Drive destination config
|
|
||||||
googleDestConfig := &TransferConfig{
|
|
||||||
Name: "Google Drive Dest Rclone Test",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/local/source/path",
|
|
||||||
DestinationType: "gdrive",
|
|
||||||
DestinationPath: "/dest/path/in/google/drive",
|
|
||||||
DestClientID: "dest_google_client_id",
|
|
||||||
DestClientSecret: "dest_google_client_secret",
|
|
||||||
DestTeamDrive: "dest_team_drive_id",
|
|
||||||
FilePattern: "*.pdf",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set authenticated status
|
|
||||||
googleDestConfig.GoogleDriveAuthenticated = &authenticated
|
|
||||||
|
|
||||||
// Create the config
|
|
||||||
err = db.CreateTransferConfig(googleDestConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Generate rclone config for destination
|
|
||||||
configPath = db.GetConfigRclonePath(googleDestConfig)
|
|
||||||
|
|
||||||
// Check that the file exists
|
|
||||||
_, err = os.Stat(configPath)
|
|
||||||
assert.NoError(t, err, "Rclone config file should exist")
|
|
||||||
|
|
||||||
// Read the config file
|
|
||||||
configContent, err = os.ReadFile(configPath)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
content = string(configContent)
|
|
||||||
|
|
||||||
// Verify it contains Google Drive specific content
|
|
||||||
assert.Contains(t, content, "type = drive")
|
|
||||||
assert.Contains(t, content, fmt.Sprintf("client_id = %s", googleDestConfig.DestClientID))
|
|
||||||
assert.Contains(t, content, "dest")
|
|
||||||
assert.Contains(t, content, fmt.Sprintf("team_drive = %s", googleDestConfig.DestTeamDrive))
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
err = db.Delete(&googleSourceConfig).Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
err = db.Delete(&googleDestConfig).Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role represents a role in the system with associated permissions
|
||||||
|
type Role struct {
|
||||||
|
gorm.Model
|
||||||
|
Name string `gorm:"size:255;not null;unique"`
|
||||||
|
Description string `gorm:"type:text"`
|
||||||
|
Permissions Permissions `gorm:"type:text"`
|
||||||
|
Users []User `gorm:"many2many:user_roles;"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignToUser assigns this role to a user
|
||||||
|
func (r *Role) AssignToUser(tx *gorm.DB, userID uint, assignedByID uint) error {
|
||||||
|
// Check if the role is already assigned
|
||||||
|
var count int64
|
||||||
|
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", userID, r.ID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New("role is already assigned to user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the assignment
|
||||||
|
if err := tx.Exec("INSERT INTO user_roles (user_id, role_id, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
userID, r.ID, time.Now(), time.Now()).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log
|
||||||
|
return tx.Create(&AuditLog{
|
||||||
|
Action: "assign_role",
|
||||||
|
EntityType: "role",
|
||||||
|
EntityID: r.ID,
|
||||||
|
UserID: assignedByID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"role_name": r.Name,
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnassignFromUser removes this role from a user
|
||||||
|
func (r *Role) UnassignFromUser(tx *gorm.DB, userID uint, unassignedByID uint) error {
|
||||||
|
// Check if the role is assigned
|
||||||
|
var count int64
|
||||||
|
if err := tx.Table("user_roles").Where("user_id = ? AND role_id = ?", userID, r.ID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return errors.New("role is not assigned to user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the assignment
|
||||||
|
if err := tx.Exec("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userID, r.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log
|
||||||
|
return tx.Create(&AuditLog{
|
||||||
|
Action: "unassign_role",
|
||||||
|
EntityType: "role",
|
||||||
|
EntityID: r.ID,
|
||||||
|
UserID: unassignedByID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"role_name": r.Name,
|
||||||
|
"user_id": userID,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate performs validation on the role
|
||||||
|
func (r *Role) Validate() error {
|
||||||
|
// Name validation
|
||||||
|
if strings.TrimSpace(r.Name) == "" {
|
||||||
|
return errors.New("role name cannot be empty")
|
||||||
|
}
|
||||||
|
if len(r.Name) > 255 {
|
||||||
|
return errors.New("role name cannot exceed 255 characters")
|
||||||
|
}
|
||||||
|
if len(r.Description) > 1000 {
|
||||||
|
return errors.New("description cannot exceed 1000 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission validation
|
||||||
|
if r.Permissions == nil {
|
||||||
|
return errors.New("permissions cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each permission
|
||||||
|
validPerms := map[string]bool{
|
||||||
|
// User management
|
||||||
|
"users.view": true,
|
||||||
|
"users.create": true,
|
||||||
|
"users.edit": true,
|
||||||
|
"users.delete": true,
|
||||||
|
|
||||||
|
// Role management
|
||||||
|
"roles.view": true,
|
||||||
|
"roles.create": true,
|
||||||
|
"roles.edit": true,
|
||||||
|
"roles.delete": true,
|
||||||
|
|
||||||
|
// Config management
|
||||||
|
"configs.view": true,
|
||||||
|
"configs.create": true,
|
||||||
|
"configs.edit": true,
|
||||||
|
"configs.delete": true,
|
||||||
|
|
||||||
|
// Job management
|
||||||
|
"jobs.view": true,
|
||||||
|
"jobs.create": true,
|
||||||
|
"jobs.edit": true,
|
||||||
|
"jobs.delete": true,
|
||||||
|
"jobs.run": true,
|
||||||
|
|
||||||
|
// Audit logs
|
||||||
|
"audit.view": true,
|
||||||
|
"audit.export": true,
|
||||||
|
|
||||||
|
// System settings
|
||||||
|
"system.settings": true,
|
||||||
|
"system.backup": true,
|
||||||
|
"system.restore": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, perm := range r.Permissions {
|
||||||
|
if !validPerms[perm] {
|
||||||
|
return errors.New("invalid permission: " + perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeSave is a GORM hook that runs before saving the role
|
||||||
|
func (r *Role) BeforeSave(tx *gorm.DB) error {
|
||||||
|
return r.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeDelete is a GORM hook that runs before deleting the role
|
||||||
|
func (r *Role) BeforeDelete(tx *gorm.DB) error {
|
||||||
|
if r.IsSystemRole() {
|
||||||
|
return errors.New("cannot delete system role")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit log for deletion
|
||||||
|
return tx.Create(&AuditLog{
|
||||||
|
Action: "delete_role",
|
||||||
|
EntityType: "role",
|
||||||
|
EntityID: r.ID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"role_name": r.Name,
|
||||||
|
"permissions": r.Permissions,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AfterCreate is a GORM hook that runs after creating the role
|
||||||
|
func (r *Role) AfterCreate(tx *gorm.DB) error {
|
||||||
|
// Create audit log for creation
|
||||||
|
return tx.Create(&AuditLog{
|
||||||
|
Action: "create_role",
|
||||||
|
EntityType: "role",
|
||||||
|
EntityID: r.ID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"role_name": r.Name,
|
||||||
|
"permissions": r.Permissions,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AfterUpdate is a GORM hook that runs after updating the role
|
||||||
|
func (r *Role) AfterUpdate(tx *gorm.DB) error {
|
||||||
|
// Create audit log for update
|
||||||
|
return tx.Create(&AuditLog{
|
||||||
|
Action: "update_role",
|
||||||
|
EntityType: "role",
|
||||||
|
EntityID: r.ID,
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"role_name": r.Name,
|
||||||
|
"permissions": r.Permissions,
|
||||||
|
},
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions is a custom type for storing permissions as a JSON array
|
||||||
|
type Permissions []string
|
||||||
|
|
||||||
|
// Scan implements the sql.Scanner interface
|
||||||
|
func (p *Permissions) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*p = make(Permissions, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var str string
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
str = string(v)
|
||||||
|
case string:
|
||||||
|
str = v
|
||||||
|
default:
|
||||||
|
return errors.New("failed to scan Permissions: invalid type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal([]byte(str), p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver.Valuer interface
|
||||||
|
func (p Permissions) Value() (driver.Value, error) {
|
||||||
|
if p == nil {
|
||||||
|
p = make(Permissions, 0)
|
||||||
|
}
|
||||||
|
bytes, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermission checks if the role has a specific permission
|
||||||
|
func (r *Role) HasPermission(permission string) bool {
|
||||||
|
for _, p := range r.Permissions {
|
||||||
|
if p == permission {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPermission adds a permission to the role if it doesn't already exist
|
||||||
|
func (r *Role) AddPermission(permission string) {
|
||||||
|
if !r.HasPermission(permission) {
|
||||||
|
r.Permissions = append(r.Permissions, permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePermission removes a permission from the role
|
||||||
|
func (r *Role) RemovePermission(permission string) {
|
||||||
|
if r.Permissions == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, p := range r.Permissions {
|
||||||
|
if p == permission {
|
||||||
|
r.Permissions = append(r.Permissions[:i], r.Permissions[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPermissions sets the role's permissions
|
||||||
|
func (r *Role) SetPermissions(permissions []string) {
|
||||||
|
r.Permissions = permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissions returns the role's permissions
|
||||||
|
func (r *Role) GetPermissions() []string {
|
||||||
|
if r.Permissions == nil {
|
||||||
|
return make([]string, 0)
|
||||||
|
}
|
||||||
|
return r.Permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSystemRole checks if this is a system-defined role that shouldn't be modified
|
||||||
|
func (r *Role) IsSystemRole() bool {
|
||||||
|
systemRoles := []string{"admin", "system", "superuser"}
|
||||||
|
for _, role := range systemRoles {
|
||||||
|
if strings.ToLower(r.Name) == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLog creates an audit log entry for role changes
|
||||||
|
func (r *Role) AuditLog(tx *gorm.DB, action string, userID uint) error {
|
||||||
|
return tx.Create(&AuditLog{
|
||||||
|
Action: action,
|
||||||
|
EntityType: "role",
|
||||||
|
EntityID: r.ID,
|
||||||
|
UserID: userID,
|
||||||
|
Details: map[string]interface{}{"name": r.Name, "permissions": r.Permissions},
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestDeleteTransferConfigWithTransaction tests the DeleteTransferConfig function with transaction scenarios
|
|
||||||
func TestDeleteTransferConfigWithTransaction(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
testUser := &User{
|
|
||||||
Email: "delete-config-test@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err := db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a test config
|
|
||||||
testConfig := &TransferConfig{
|
|
||||||
Name: "Test Delete Config",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/source/path",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/destination/path",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateTransferConfig(testConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test successful deletion
|
|
||||||
err = db.DeleteTransferConfig(testConfig.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
_, err = db.GetTransferConfig(testConfig.ID)
|
|
||||||
assert.Error(t, err, "Config should be deleted")
|
|
||||||
|
|
||||||
// Test deletion with transaction that's rolled back
|
|
||||||
// Create another config
|
|
||||||
testConfig2 := &TransferConfig{
|
|
||||||
Name: "Test Delete Config 2",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/source/path2",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/destination/path2",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateTransferConfig(testConfig2)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Start a transaction
|
|
||||||
tx := db.Begin()
|
|
||||||
assert.NotNil(t, tx)
|
|
||||||
|
|
||||||
// Delete the config within the transaction
|
|
||||||
err = tx.Delete(&TransferConfig{}, testConfig2.ID).Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Rollback the transaction
|
|
||||||
tx.Rollback()
|
|
||||||
|
|
||||||
// Verify the config still exists
|
|
||||||
config, err := db.GetTransferConfig(testConfig2.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, config)
|
|
||||||
assert.Equal(t, testConfig2.ID, config.ID)
|
|
||||||
|
|
||||||
// Test deletion with a committed transaction
|
|
||||||
tx = db.Begin()
|
|
||||||
assert.NotNil(t, tx)
|
|
||||||
|
|
||||||
// Delete the config within the transaction
|
|
||||||
err = tx.Delete(&TransferConfig{}, testConfig2.ID).Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Commit the transaction
|
|
||||||
tx.Commit()
|
|
||||||
|
|
||||||
// Verify the config is deleted
|
|
||||||
_, err = db.GetTransferConfig(testConfig2.ID)
|
|
||||||
assert.Error(t, err, "Config should be deleted after commit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteJobWithTransaction tests the DeleteJob function with transaction scenarios
|
|
||||||
func TestDeleteJobWithTransaction(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
testUser := &User{
|
|
||||||
Email: "delete-job-test@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
LastPasswordChange: time.Now(),
|
|
||||||
}
|
|
||||||
err := db.CreateUser(testUser)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a test transfer config
|
|
||||||
testConfig := &TransferConfig{
|
|
||||||
Name: "Test Delete Job Config",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: "/source/path",
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: "/destination/path",
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateTransferConfig(testConfig)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a test job
|
|
||||||
testJob := &Job{
|
|
||||||
Name: "Test Delete Job",
|
|
||||||
ConfigID: testConfig.ID,
|
|
||||||
Schedule: "0 * * * *", // Run hourly
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateJob(testJob)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test successful deletion
|
|
||||||
err = db.DeleteJob(testJob.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
_, err = db.GetJob(testJob.ID)
|
|
||||||
assert.Error(t, err, "Job should be deleted")
|
|
||||||
|
|
||||||
// Test deletion with transaction that's rolled back
|
|
||||||
// Create another job
|
|
||||||
testJob2 := &Job{
|
|
||||||
Name: "Test Delete Job 2",
|
|
||||||
ConfigID: testConfig.ID,
|
|
||||||
Schedule: "0 * * * *", // Run hourly
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
CreatedBy: testUser.ID,
|
|
||||||
}
|
|
||||||
err = db.CreateJob(testJob2)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Start a transaction
|
|
||||||
tx := db.Begin()
|
|
||||||
assert.NotNil(t, tx)
|
|
||||||
|
|
||||||
// Delete the job within the transaction
|
|
||||||
err = tx.Delete(&Job{}, testJob2.ID).Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Rollback the transaction
|
|
||||||
tx.Rollback()
|
|
||||||
|
|
||||||
// Verify the job still exists
|
|
||||||
job, err := db.GetJob(testJob2.ID)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, job)
|
|
||||||
assert.Equal(t, testJob2.ID, job.ID)
|
|
||||||
|
|
||||||
// Test deletion with a committed transaction
|
|
||||||
tx = db.Begin()
|
|
||||||
assert.NotNil(t, tx)
|
|
||||||
|
|
||||||
// Delete the job within the transaction
|
|
||||||
err = tx.Delete(&Job{}, testJob2.ID).Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Commit the transaction
|
|
||||||
tx.Commit()
|
|
||||||
|
|
||||||
// Verify the job is deleted
|
|
||||||
_, err = db.GetJob(testJob2.ID)
|
|
||||||
assert.Error(t, err, "Job should be deleted after commit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTransactionHelpers tests transaction helper methods
|
|
||||||
func TestTransactionHelpers(t *testing.T) {
|
|
||||||
db := setupTestDB(t)
|
|
||||||
|
|
||||||
// Test Begin and Rollback
|
|
||||||
tx := db.Begin()
|
|
||||||
assert.NotNil(t, tx)
|
|
||||||
assert.IsType(t, &gorm.DB{}, tx)
|
|
||||||
|
|
||||||
// Rollback should succeed
|
|
||||||
err := tx.Rollback().Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test Begin and Commit
|
|
||||||
tx = db.Begin()
|
|
||||||
assert.NotNil(t, tx)
|
|
||||||
|
|
||||||
// Commit should succeed
|
|
||||||
err = tx.Commit().Error
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationType defines the type of notification
|
||||||
|
type NotificationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotificationJobStart NotificationType = "job_start"
|
||||||
|
NotificationJobComplete NotificationType = "job_complete"
|
||||||
|
NotificationJobFail NotificationType = "job_fail"
|
||||||
|
NotificationConfigUpdate NotificationType = "config_update"
|
||||||
|
NotificationSystemAlert NotificationType = "system_alert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserNotification represents a notification shown to users in the UI
|
||||||
|
type UserNotification struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
UserID uint `json:"user_id" gorm:"index"`
|
||||||
|
Type NotificationType `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
JobID uint `json:"job_id,omitempty"`
|
||||||
|
JobRunID uint `json:"job_run_id,omitempty"`
|
||||||
|
ConfigID uint `json:"config_id,omitempty"`
|
||||||
|
IsRead bool `json:"is_read" gorm:"default:false"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserNotifications returns the latest notifications for a user
|
||||||
|
func (db *DB) GetUserNotifications(userID uint, limit int) ([]UserNotification, error) {
|
||||||
|
var notifications []UserNotification
|
||||||
|
result := db.Where("user_id = ?", userID).Order("created_at DESC").Limit(limit).Find(¬ifications)
|
||||||
|
return notifications, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserNotificationCount returns the total count of notifications for a user
|
||||||
|
func (db *DB) GetUserNotificationCount(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
result := db.Model(&UserNotification{}).Where("user_id = ?", userID).Count(&count)
|
||||||
|
return count, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPaginatedUserNotifications returns paginated notifications for a user
|
||||||
|
func (db *DB) GetPaginatedUserNotifications(userID uint, offset, limit int) ([]UserNotification, error) {
|
||||||
|
var notifications []UserNotification
|
||||||
|
result := db.Where("user_id = ?", userID).Order("created_at DESC").Offset(offset).Limit(limit).Find(¬ifications)
|
||||||
|
return notifications, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnreadNotificationCount returns the count of unread notifications for a user
|
||||||
|
func (db *DB) GetUnreadNotificationCount(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
result := db.Model(&UserNotification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&count)
|
||||||
|
return count, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationAsRead marks a notification as read
|
||||||
|
func (db *DB) MarkNotificationAsRead(id uint) error {
|
||||||
|
return db.Model(&UserNotification{}).Where("id = ?", id).Update("is_read", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllNotificationsAsRead marks all notifications for a user as read
|
||||||
|
func (db *DB) MarkAllNotificationsAsRead(userID uint) error {
|
||||||
|
return db.Model(&UserNotification{}).Where("user_id = ?", userID).Update("is_read", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJobNotification creates a notification for a job event
|
||||||
|
func (db *DB) CreateJobNotification(
|
||||||
|
userID uint,
|
||||||
|
jobID uint,
|
||||||
|
jobRunID uint,
|
||||||
|
notificationType NotificationType,
|
||||||
|
title string,
|
||||||
|
message string,
|
||||||
|
) error {
|
||||||
|
notification := UserNotification{
|
||||||
|
UserID: userID,
|
||||||
|
Type: notificationType,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
JobID: jobID,
|
||||||
|
JobRunID: jobRunID,
|
||||||
|
Link: generateJobRunLink(jobRunID),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
return db.Create(¬ification).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConfigNotification creates a notification for a config update
|
||||||
|
func (db *DB) CreateConfigNotification(
|
||||||
|
userID uint,
|
||||||
|
configID uint,
|
||||||
|
title string,
|
||||||
|
message string,
|
||||||
|
) error {
|
||||||
|
notification := UserNotification{
|
||||||
|
UserID: userID,
|
||||||
|
Type: NotificationConfigUpdate,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
ConfigID: configID,
|
||||||
|
Link: generateConfigLink(configID),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
return db.Create(¬ification).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSystemNotification creates a system-wide notification
|
||||||
|
func (db *DB) CreateSystemNotification(
|
||||||
|
title string,
|
||||||
|
message string,
|
||||||
|
) error {
|
||||||
|
// Get all active users
|
||||||
|
var users []User
|
||||||
|
if err := db.Where("active = ?", true).Find(&users).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a notification for each user
|
||||||
|
for _, user := range users {
|
||||||
|
notification := UserNotification{
|
||||||
|
UserID: user.ID,
|
||||||
|
Type: NotificationSystemAlert,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.Create(¬ification).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to generate links
|
||||||
|
func generateJobRunLink(jobRunID uint) string {
|
||||||
|
return "/job-runs/" + fmt.Sprintf("%d", jobRunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateConfigLink(configID uint) string {
|
||||||
|
return "/configs/" + fmt.Sprintf("%d", configID)
|
||||||
|
}
|
||||||
@@ -270,3 +270,182 @@ func (s *Service) sendEmail(toEmail, subject, htmlContent string) error {
|
|||||||
return client.Quit()
|
return client.Quit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendTestEmail sends a test email to verify email configuration
|
||||||
|
func (s *Service) SendTestEmail(toEmail, subject, message string) error {
|
||||||
|
if !s.Config.Email.Enabled {
|
||||||
|
return fmt.Errorf("email service is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default subject if not provided
|
||||||
|
if subject == "" {
|
||||||
|
subject = "Test Email from GoMFT"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default message if not provided
|
||||||
|
if message == "" {
|
||||||
|
message = "This is a test email from GoMFT to verify the email configuration is working correctly."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create email data for template
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Subject": subject,
|
||||||
|
"Message": message,
|
||||||
|
"AppName": "GoMFT",
|
||||||
|
"Year": time.Now().Year(),
|
||||||
|
"SMTPServer": s.Config.Email.Host,
|
||||||
|
"SMTPPort": s.Config.Email.Port,
|
||||||
|
"FromEmail": s.Config.Email.FromEmail,
|
||||||
|
"CurrentTime": time.Now().Format(time.RFC1123Z),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate email content
|
||||||
|
htmlContent, err := s.generateTestEmailHTML(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
return s.sendEmail(toEmail, subject, htmlContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestEmailHTML generates the HTML content for test emails
|
||||||
|
func (s *Service) generateTestEmailHTML(data map[string]interface{}) (string, error) {
|
||||||
|
// HTML template for test email
|
||||||
|
tmpl, err := template.New("testEmail").Parse(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Subject}}</title>
|
||||||
|
<style>
|
||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin: 0 auto 15px;
|
||||||
|
background-color: #2563eb;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.logo-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 15px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-weight: bold;
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 20px 0;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">G</div>
|
||||||
|
</div>
|
||||||
|
<h1>{{.Subject}}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>{{.Message}}</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">SMTP Server:</div>
|
||||||
|
<div>{{.SMTPServer}}:{{.SMTPPort}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">From:</div>
|
||||||
|
<div>{{.FromEmail}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Sent:</div>
|
||||||
|
<div>{{.CurrentTime}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<p>This is a test email sent from the GoMFT admin interface. If you've received this email, your email configuration is working correctly.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© {{.Year}} {{.AppName}}. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&result, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
package email
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Setup test configuration without using testutils (to avoid import cycles)
|
|
||||||
func setupTestConfig(t *testing.T) *config.Config {
|
|
||||||
tempDir, err := os.MkdirTemp("", "gomft-test-*")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp directory: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
os.RemoveAll(tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
return &config.Config{
|
|
||||||
ServerAddress: ":9090",
|
|
||||||
DataDir: filepath.Join(tempDir, "data"),
|
|
||||||
BackupDir: filepath.Join(tempDir, "backups"),
|
|
||||||
JWTSecret: "test-jwt-secret",
|
|
||||||
BaseURL: "http://test.example.com",
|
|
||||||
Email: config.EmailConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Host: "smtp.test.com",
|
|
||||||
Port: 587,
|
|
||||||
Username: "test@example.com",
|
|
||||||
Password: "test-password",
|
|
||||||
FromEmail: "test@example.com",
|
|
||||||
FromName: "Test",
|
|
||||||
EnableTLS: true,
|
|
||||||
RequireAuth: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmailServiceDisabled(t *testing.T) {
|
|
||||||
// Set up test config with email disabled
|
|
||||||
cfg := setupTestConfig(t)
|
|
||||||
cfg.Email.Enabled = false
|
|
||||||
|
|
||||||
// Create the email service
|
|
||||||
service := NewService(cfg)
|
|
||||||
|
|
||||||
// Send a password reset email
|
|
||||||
err := service.SendPasswordResetEmail("test@example.com", "Test User", "token123")
|
|
||||||
|
|
||||||
// Expect an error indicating the service is disabled
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error when email service is disabled, but got none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that the error message contains the reset link
|
|
||||||
expectedMsg := cfg.BaseURL + "/reset-password?token=token123"
|
|
||||||
if !strings.Contains(err.Error(), expectedMsg) {
|
|
||||||
t.Errorf("Expected error message to contain the reset link %s, got: %s", expectedMsg, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeneratePasswordResetEmailHTML(t *testing.T) {
|
|
||||||
// Set up test config
|
|
||||||
cfg := setupTestConfig(t)
|
|
||||||
service := NewService(cfg)
|
|
||||||
|
|
||||||
// Test cases
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
data map[string]interface{}
|
|
||||||
expected []string // Strings that should be included in the HTML
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Complete user data",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"Username": "John Doe",
|
|
||||||
"ResetLink": "http://example.com/reset?token=abc123",
|
|
||||||
"AppName": "GoMFT",
|
|
||||||
"Year": 2023,
|
|
||||||
"ExpiresHours": 0.25,
|
|
||||||
},
|
|
||||||
expected: []string{
|
|
||||||
"Hello John Doe",
|
|
||||||
"http://example.com/reset?token=abc123",
|
|
||||||
"GoMFT",
|
|
||||||
"2023",
|
|
||||||
"15 minutes",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No username",
|
|
||||||
data: map[string]interface{}{
|
|
||||||
"ResetLink": "http://example.com/reset?token=abc123",
|
|
||||||
"AppName": "GoMFT",
|
|
||||||
"Year": 2023,
|
|
||||||
"ExpiresHours": 0.25,
|
|
||||||
},
|
|
||||||
expected: []string{
|
|
||||||
"Hello",
|
|
||||||
"http://example.com/reset?token=abc123",
|
|
||||||
"GoMFT",
|
|
||||||
"2023",
|
|
||||||
"15 minutes",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
// Generate HTML
|
|
||||||
html, err := service.generatePasswordResetEmailHTML(tc.data)
|
|
||||||
|
|
||||||
// Check for errors
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error generating HTML: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that all expected strings are included
|
|
||||||
for _, expected := range tc.expected {
|
|
||||||
if !strings.Contains(html, expected) {
|
|
||||||
t.Errorf("Expected HTML to contain %q, but it doesn't", expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package scheduler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMockScheduler_MultiConfig(t *testing.T) {
|
|
||||||
// Create a new mock scheduler
|
|
||||||
mockScheduler := NewMockScheduler()
|
|
||||||
|
|
||||||
// Create a job with multiple configurations
|
|
||||||
job := &db.Job{
|
|
||||||
ID: 1,
|
|
||||||
Name: "Multi-Config Test Job",
|
|
||||||
Schedule: "*/5 * * * *",
|
|
||||||
ConfigID: 1, // Primary config ID
|
|
||||||
}
|
|
||||||
job.SetEnabled(true)
|
|
||||||
|
|
||||||
// Set multiple config IDs
|
|
||||||
job.SetConfigIDsList([]uint{1, 2, 3})
|
|
||||||
|
|
||||||
// Schedule the job
|
|
||||||
err := mockScheduler.ScheduleJob(job)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Check if the job is marked as scheduled
|
|
||||||
assert.True(t, mockScheduler.ScheduledJobs[job.ID])
|
|
||||||
|
|
||||||
// Verify that the job is detected as having multiple configs
|
|
||||||
assert.True(t, mockScheduler.IsJobWithMultipleConfigs(job.ID))
|
|
||||||
|
|
||||||
// Verify the configs associated with the job
|
|
||||||
configs := mockScheduler.GetConfigsForJob(job.ID)
|
|
||||||
assert.Len(t, configs, 3)
|
|
||||||
assert.Contains(t, configs, uint(1))
|
|
||||||
assert.Contains(t, configs, uint(2))
|
|
||||||
assert.Contains(t, configs, uint(3))
|
|
||||||
|
|
||||||
// Test unscheduling the job
|
|
||||||
mockScheduler.UnscheduleJob(job.ID)
|
|
||||||
assert.True(t, mockScheduler.UnscheduledJobs[job.ID])
|
|
||||||
assert.False(t, mockScheduler.ScheduledJobs[job.ID])
|
|
||||||
|
|
||||||
// Verify the job is no longer tracked in multi-config jobs
|
|
||||||
assert.False(t, mockScheduler.IsJobWithMultipleConfigs(job.ID))
|
|
||||||
assert.Empty(t, mockScheduler.GetConfigsForJob(job.ID))
|
|
||||||
|
|
||||||
// Test a job with a single config
|
|
||||||
singleConfigJob := &db.Job{
|
|
||||||
ID: 2,
|
|
||||||
Name: "Single Config Job",
|
|
||||||
Schedule: "0 0 * * *",
|
|
||||||
ConfigID: 4,
|
|
||||||
}
|
|
||||||
singleConfigJob.SetEnabled(true)
|
|
||||||
|
|
||||||
// Set a single config ID
|
|
||||||
singleConfigJob.SetConfigIDsList([]uint{4})
|
|
||||||
|
|
||||||
// Schedule the job
|
|
||||||
err = mockScheduler.ScheduleJob(singleConfigJob)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Not considered a multi-config job if it has only one config
|
|
||||||
assert.False(t, mockScheduler.IsJobWithMultipleConfigs(singleConfigJob.ID))
|
|
||||||
|
|
||||||
// Should still contain the single config
|
|
||||||
singleConfigs := mockScheduler.GetConfigsForJob(singleConfigJob.ID)
|
|
||||||
assert.Len(t, singleConfigs, 1)
|
|
||||||
assert.Contains(t, singleConfigs, uint(4))
|
|
||||||
}
|
|
||||||
+1494
-28
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,567 +0,0 @@
|
|||||||
package scheduler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/starfleetcptn/gomft/internal/db"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestJobExecutionWebhook tests that webhooks are correctly sent during actual job execution
|
|
||||||
func TestJobExecutionWebhook(t *testing.T) {
|
|
||||||
// Skip in short mode
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("Skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up a temporary data directory for logs
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Set DATA_DIR environment variable for the test
|
|
||||||
originalDataDir := os.Getenv("DATA_DIR")
|
|
||||||
t.Setenv("DATA_DIR", tempDir)
|
|
||||||
defer os.Setenv("DATA_DIR", originalDataDir)
|
|
||||||
|
|
||||||
// Create a test database
|
|
||||||
database := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
user := &db.User{
|
|
||||||
Email: "webhook-integration@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
IsAdmin: BoolPtr(true),
|
|
||||||
}
|
|
||||||
err := database.CreateUser(user)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set up a mock HTTP server to receive webhook notifications
|
|
||||||
var (
|
|
||||||
receivedPayload []byte
|
|
||||||
receivedHeaders http.Header
|
|
||||||
webhookCalled bool
|
|
||||||
webhookMutex sync.Mutex
|
|
||||||
waitCh = make(chan struct{})
|
|
||||||
)
|
|
||||||
|
|
||||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
webhookMutex.Lock()
|
|
||||||
defer webhookMutex.Unlock()
|
|
||||||
|
|
||||||
receivedHeaders = r.Header.Clone()
|
|
||||||
var err error
|
|
||||||
receivedPayload, err = io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("Error reading request body: %v", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Logf("Received webhook payload: %s", string(receivedPayload))
|
|
||||||
webhookCalled = true
|
|
||||||
close(waitCh)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer mockServer.Close()
|
|
||||||
t.Logf("Mock server URL: %s", mockServer.URL)
|
|
||||||
|
|
||||||
// Create local source and destination directories
|
|
||||||
sourceDir := t.TempDir()
|
|
||||||
destDir := t.TempDir()
|
|
||||||
t.Logf("Source directory: %s", sourceDir)
|
|
||||||
t.Logf("Destination directory: %s", destDir)
|
|
||||||
|
|
||||||
// Create a test transfer config with local source and destination
|
|
||||||
config := &db.TransferConfig{
|
|
||||||
Name: "Webhook Integration Config",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: sourceDir,
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: destDir,
|
|
||||||
CreatedBy: user.ID,
|
|
||||||
}
|
|
||||||
err = database.DB.Create(config).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Logf("Created config with ID: %d", config.ID)
|
|
||||||
|
|
||||||
// Create a test job with webhook enabled
|
|
||||||
job := &db.Job{
|
|
||||||
Name: "Webhook Integration Job",
|
|
||||||
ConfigID: config.ID,
|
|
||||||
Schedule: "*/5 * * * *", // not actually used in this test
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
WebhookEnabled: BoolPtr(true),
|
|
||||||
WebhookURL: mockServer.URL,
|
|
||||||
NotifyOnSuccess: BoolPtr(true),
|
|
||||||
NotifyOnFailure: BoolPtr(true),
|
|
||||||
CreatedBy: user.ID,
|
|
||||||
}
|
|
||||||
err = database.DB.Create(job).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
t.Logf("Created job with ID %d, NotifyOnSuccess=%v", job.ID, job.NotifyOnSuccess)
|
|
||||||
|
|
||||||
// Create and initialize the scheduler
|
|
||||||
scheduler := New(database)
|
|
||||||
defer scheduler.Stop()
|
|
||||||
|
|
||||||
// Create rclone config directory and file
|
|
||||||
configDir := filepath.Join(tempDir, "configs")
|
|
||||||
err = os.MkdirAll(configDir, 0755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a minimal rclone config file
|
|
||||||
rcloneConfig := `
|
|
||||||
[source_1]
|
|
||||||
type = local
|
|
||||||
|
|
||||||
[dest_1]
|
|
||||||
type = local
|
|
||||||
`
|
|
||||||
configFile := filepath.Join(configDir, "config_1.conf")
|
|
||||||
err = os.WriteFile(configFile, []byte(rcloneConfig), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Logf("Created rclone config file: %s", configFile)
|
|
||||||
|
|
||||||
// Put a test file in the source directory
|
|
||||||
testFile := filepath.Join(sourceDir, "test.txt")
|
|
||||||
testFileContent := []byte("This is a test file for webhook integration testing.")
|
|
||||||
err = os.WriteFile(testFile, testFileContent, 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Logf("Created test file: %s", testFile)
|
|
||||||
|
|
||||||
// Check that the file exists
|
|
||||||
fileInfo, err := os.Stat(testFile)
|
|
||||||
require.NoError(t, err, "Test file should exist")
|
|
||||||
t.Logf("Test file size: %d bytes", fileInfo.Size())
|
|
||||||
|
|
||||||
// Manually trigger job execution
|
|
||||||
t.Logf("Running job now...")
|
|
||||||
err = scheduler.RunJobNow(job.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the job to complete and webhook to be called (up to 15 seconds)
|
|
||||||
t.Logf("Waiting for webhook to be called...")
|
|
||||||
timeout := time.After(15 * time.Second)
|
|
||||||
select {
|
|
||||||
case <-waitCh:
|
|
||||||
t.Logf("Webhook was called")
|
|
||||||
case <-timeout:
|
|
||||||
// Before failing, check job status
|
|
||||||
var histories []db.JobHistory
|
|
||||||
err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if len(histories) > 0 {
|
|
||||||
t.Logf("Job history found: status=%s, error=%s",
|
|
||||||
histories[0].Status, histories[0].ErrorMessage)
|
|
||||||
} else {
|
|
||||||
t.Logf("No job history found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if destination file exists
|
|
||||||
destFile := filepath.Join(destDir, "test.txt")
|
|
||||||
if _, err := os.Stat(destFile); err == nil {
|
|
||||||
t.Logf("Destination file exists, but webhook was not called")
|
|
||||||
} else {
|
|
||||||
t.Logf("Destination file does not exist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookMutex.Lock()
|
|
||||||
called := webhookCalled
|
|
||||||
webhookMutex.Unlock()
|
|
||||||
|
|
||||||
if called {
|
|
||||||
t.Logf("Webhook was actually called but channel synchronization failed")
|
|
||||||
} else {
|
|
||||||
t.Fatal("Timed out waiting for webhook to be called")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the webhook notification
|
|
||||||
webhookMutex.Lock()
|
|
||||||
payload := receivedPayload
|
|
||||||
headers := receivedHeaders
|
|
||||||
webhookMutex.Unlock()
|
|
||||||
|
|
||||||
assert.NotNil(t, payload, "Webhook notification should have been sent")
|
|
||||||
|
|
||||||
// Verify the payload content
|
|
||||||
var payloadMap map[string]interface{}
|
|
||||||
err = json.Unmarshal(payload, &payloadMap)
|
|
||||||
require.NoError(t, err, "Failed to unmarshal webhook payload")
|
|
||||||
|
|
||||||
// Check essential fields
|
|
||||||
assert.Equal(t, "job_execution", payloadMap["event_type"])
|
|
||||||
assert.Equal(t, float64(job.ID), payloadMap["job_id"])
|
|
||||||
assert.Equal(t, job.Name, payloadMap["job_name"])
|
|
||||||
assert.Equal(t, float64(config.ID), payloadMap["config_id"])
|
|
||||||
assert.Equal(t, config.Name, payloadMap["config_name"])
|
|
||||||
|
|
||||||
// Check status (should be "completed" or "completed_with_errors")
|
|
||||||
status, ok := payloadMap["status"].(string)
|
|
||||||
require.True(t, ok, "Status should be a string")
|
|
||||||
assert.Contains(t, []string{"completed", "completed_with_errors"}, status)
|
|
||||||
|
|
||||||
// Check that we have bytes transferred
|
|
||||||
bytesTransferred, ok := payloadMap["bytes_transferred"].(float64)
|
|
||||||
require.True(t, ok, "bytes_transferred should be a number")
|
|
||||||
assert.Greater(t, bytesTransferred, float64(0))
|
|
||||||
|
|
||||||
// Check that we have files transferred
|
|
||||||
filesTransferred, ok := payloadMap["files_transferred"].(float64)
|
|
||||||
require.True(t, ok, "files_transferred should be a number")
|
|
||||||
assert.Equal(t, float64(1), filesTransferred)
|
|
||||||
|
|
||||||
// Check standard headers
|
|
||||||
assert.Equal(t, "application/json", headers.Get("Content-Type"))
|
|
||||||
assert.Equal(t, "GoMFT-Webhook/1.0", headers.Get("User-Agent"))
|
|
||||||
|
|
||||||
// Check that the file was actually transferred
|
|
||||||
destFile := filepath.Join(destDir, "test.txt")
|
|
||||||
_, err = os.Stat(destFile)
|
|
||||||
assert.NoError(t, err, "The file should have been transferred")
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
err = database.DB.Unscoped().Delete(job).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFailedJobWebhook tests that webhooks are correctly sent for failed jobs
|
|
||||||
func TestFailedJobWebhook(t *testing.T) {
|
|
||||||
// Skip in short mode
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("Skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up a temporary data directory for logs
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Set DATA_DIR environment variable for the test
|
|
||||||
originalDataDir := os.Getenv("DATA_DIR")
|
|
||||||
t.Setenv("DATA_DIR", tempDir)
|
|
||||||
defer os.Setenv("DATA_DIR", originalDataDir)
|
|
||||||
|
|
||||||
// Create a test database
|
|
||||||
database := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
user := &db.User{
|
|
||||||
Email: "webhook-failure@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
IsAdmin: BoolPtr(true),
|
|
||||||
}
|
|
||||||
err := database.CreateUser(user)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set up a mock HTTP server to receive webhook notifications
|
|
||||||
var (
|
|
||||||
receivedPayload []byte
|
|
||||||
webhookCalled bool
|
|
||||||
webhookMutex sync.Mutex
|
|
||||||
waitCh = make(chan struct{})
|
|
||||||
)
|
|
||||||
|
|
||||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
webhookMutex.Lock()
|
|
||||||
defer webhookMutex.Unlock()
|
|
||||||
|
|
||||||
var err error
|
|
||||||
receivedPayload, err = io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("Error reading request body: %v", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Logf("Received webhook payload: %s", string(receivedPayload))
|
|
||||||
webhookCalled = true
|
|
||||||
close(waitCh)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer mockServer.Close()
|
|
||||||
|
|
||||||
// Get a non-existent directory for source
|
|
||||||
nonexistentDir := filepath.Join(t.TempDir(), "non-existent-subdirectory")
|
|
||||||
|
|
||||||
// Create a legitimate destination directory
|
|
||||||
destDir := t.TempDir()
|
|
||||||
|
|
||||||
// Create a test transfer config with invalid source (to trigger failure)
|
|
||||||
config := &db.TransferConfig{
|
|
||||||
Name: "Webhook Failure Config",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: nonexistentDir,
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: destDir,
|
|
||||||
CreatedBy: user.ID,
|
|
||||||
}
|
|
||||||
err = database.DB.Create(config).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Logf("Created config with invalid source path: %s", nonexistentDir)
|
|
||||||
|
|
||||||
// Create a test job with webhook enabled
|
|
||||||
job := &db.Job{
|
|
||||||
Name: "Webhook Failure Job",
|
|
||||||
ConfigID: config.ID,
|
|
||||||
Schedule: "*/5 * * * *", // not actually used in this test
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
WebhookEnabled: BoolPtr(true),
|
|
||||||
WebhookURL: mockServer.URL,
|
|
||||||
NotifyOnSuccess: BoolPtr(true),
|
|
||||||
NotifyOnFailure: BoolPtr(true),
|
|
||||||
CreatedBy: user.ID,
|
|
||||||
}
|
|
||||||
err = database.DB.Create(job).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create and initialize the scheduler
|
|
||||||
scheduler := New(database)
|
|
||||||
defer scheduler.Stop()
|
|
||||||
|
|
||||||
// Create rclone config directory and file
|
|
||||||
configDir := filepath.Join(tempDir, "configs")
|
|
||||||
err = os.MkdirAll(configDir, 0755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a minimal rclone config file
|
|
||||||
rcloneConfig := `
|
|
||||||
[source_1]
|
|
||||||
type = local
|
|
||||||
|
|
||||||
[dest_1]
|
|
||||||
type = local
|
|
||||||
`
|
|
||||||
configFile := filepath.Join(configDir, "config_1.conf")
|
|
||||||
err = os.WriteFile(configFile, []byte(rcloneConfig), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Logf("Created rclone config file: %s", configFile)
|
|
||||||
|
|
||||||
// Manually trigger job execution
|
|
||||||
t.Logf("Running job now (expecting failure)...")
|
|
||||||
err = scheduler.RunJobNow(job.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the job to complete and webhook to be called (up to 15 seconds)
|
|
||||||
t.Logf("Waiting for webhook to be called with failure notification...")
|
|
||||||
timeout := time.After(15 * time.Second)
|
|
||||||
select {
|
|
||||||
case <-waitCh:
|
|
||||||
t.Logf("Webhook was called")
|
|
||||||
case <-timeout:
|
|
||||||
// Before failing, check job status
|
|
||||||
var histories []db.JobHistory
|
|
||||||
err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if len(histories) > 0 {
|
|
||||||
t.Logf("Job history found: status=%s, error=%s",
|
|
||||||
histories[0].Status, histories[0].ErrorMessage)
|
|
||||||
} else {
|
|
||||||
t.Logf("No job history found")
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookMutex.Lock()
|
|
||||||
called := webhookCalled
|
|
||||||
webhookMutex.Unlock()
|
|
||||||
|
|
||||||
if called {
|
|
||||||
t.Logf("Webhook was actually called but channel synchronization failed")
|
|
||||||
} else {
|
|
||||||
t.Fatal("Timed out waiting for webhook to be called")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the webhook notification
|
|
||||||
assert.NotNil(t, receivedPayload, "Webhook notification should have been sent")
|
|
||||||
|
|
||||||
// Verify the payload content
|
|
||||||
var payload map[string]interface{}
|
|
||||||
err = json.Unmarshal(receivedPayload, &payload)
|
|
||||||
require.NoError(t, err, "Failed to unmarshal webhook payload")
|
|
||||||
|
|
||||||
// Check essential fields
|
|
||||||
assert.Equal(t, "job_execution", payload["event_type"])
|
|
||||||
assert.Equal(t, float64(job.ID), payload["job_id"])
|
|
||||||
assert.Equal(t, "failed", payload["status"])
|
|
||||||
|
|
||||||
// Ensure there's an error message
|
|
||||||
errorMsg, ok := payload["error_message"].(string)
|
|
||||||
require.True(t, ok, "error_message should be a string")
|
|
||||||
assert.NotEmpty(t, errorMsg)
|
|
||||||
t.Logf("Error message from webhook: %s", errorMsg)
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
err = database.DB.Unscoped().Delete(job).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDisabledForSuccessNotification tests that webhooks are not sent for
|
|
||||||
// successful jobs when notify_on_success is disabled
|
|
||||||
func TestWebhookDisabledForSuccessNotification(t *testing.T) {
|
|
||||||
// Skip in short mode
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("Skipping integration test in short mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up a temporary data directory for logs
|
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Set DATA_DIR environment variable for the test
|
|
||||||
originalDataDir := os.Getenv("DATA_DIR")
|
|
||||||
t.Setenv("DATA_DIR", tempDir)
|
|
||||||
defer os.Setenv("DATA_DIR", originalDataDir)
|
|
||||||
|
|
||||||
// Create a test database
|
|
||||||
database := setupTestDB(t)
|
|
||||||
|
|
||||||
// Create a test user
|
|
||||||
user := &db.User{
|
|
||||||
Email: "webhook-disabled@example.com",
|
|
||||||
PasswordHash: "hashed_password",
|
|
||||||
IsAdmin: BoolPtr(true),
|
|
||||||
}
|
|
||||||
err := database.CreateUser(user)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set up a mock HTTP server to receive webhook notifications
|
|
||||||
var (
|
|
||||||
webhookCalled bool
|
|
||||||
webhookMutex sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
webhookMutex.Lock()
|
|
||||||
defer webhookMutex.Unlock()
|
|
||||||
|
|
||||||
// Log the fact that webhook was called (it shouldn't be)
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
t.Logf("Unexpected webhook call received: %s", string(body))
|
|
||||||
|
|
||||||
webhookCalled = true
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer mockServer.Close()
|
|
||||||
|
|
||||||
// Create local source and destination directories
|
|
||||||
sourceDir := t.TempDir()
|
|
||||||
destDir := t.TempDir()
|
|
||||||
|
|
||||||
// Create a test transfer config with local source and destination
|
|
||||||
config := &db.TransferConfig{
|
|
||||||
Name: "Webhook Disabled Config",
|
|
||||||
SourceType: "local",
|
|
||||||
SourcePath: sourceDir,
|
|
||||||
DestinationType: "local",
|
|
||||||
DestinationPath: destDir,
|
|
||||||
CreatedBy: user.ID,
|
|
||||||
}
|
|
||||||
err = database.DB.Create(config).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a test job with webhook enabled but notify_on_success disabled
|
|
||||||
job := &db.Job{
|
|
||||||
Name: "Webhook Disabled Job",
|
|
||||||
ConfigID: config.ID,
|
|
||||||
Schedule: "*/5 * * * *", // not actually used in this test
|
|
||||||
Enabled: BoolPtr(true),
|
|
||||||
WebhookEnabled: BoolPtr(true),
|
|
||||||
WebhookURL: mockServer.URL,
|
|
||||||
NotifyOnSuccess: BoolPtr(false), // This is the key setting we're testing
|
|
||||||
NotifyOnFailure: BoolPtr(true),
|
|
||||||
CreatedBy: user.ID,
|
|
||||||
}
|
|
||||||
err = database.DB.Create(job).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Update the job to ensure the notification settings are correctly set
|
|
||||||
// This is necessary because the database has default values for these fields
|
|
||||||
err = database.DB.Model(job).Updates(map[string]interface{}{
|
|
||||||
"notify_on_success": false,
|
|
||||||
}).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Reload the job to make sure we have the correct values
|
|
||||||
var reloadedJob db.Job
|
|
||||||
err = database.DB.First(&reloadedJob, job.ID).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
job = &reloadedJob
|
|
||||||
|
|
||||||
t.Logf("Created job with ID %d, NotifyOnSuccess=%v", job.ID, job.NotifyOnSuccess)
|
|
||||||
|
|
||||||
// Create and initialize the scheduler
|
|
||||||
scheduler := New(database)
|
|
||||||
defer scheduler.Stop()
|
|
||||||
|
|
||||||
// Create rclone config directory and file
|
|
||||||
configDir := filepath.Join(tempDir, "configs")
|
|
||||||
err = os.MkdirAll(configDir, 0755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a minimal rclone config file
|
|
||||||
rcloneConfig := `
|
|
||||||
[source_1]
|
|
||||||
type = local
|
|
||||||
|
|
||||||
[dest_1]
|
|
||||||
type = local
|
|
||||||
`
|
|
||||||
configFile := filepath.Join(configDir, "config_1.conf")
|
|
||||||
err = os.WriteFile(configFile, []byte(rcloneConfig), 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Logf("Created rclone config file: %s", configFile)
|
|
||||||
|
|
||||||
// Put a test file in the source directory
|
|
||||||
testFile := filepath.Join(sourceDir, "test.txt")
|
|
||||||
testFileContent := []byte("This is a test file for disabled webhook testing.")
|
|
||||||
err = os.WriteFile(testFile, testFileContent, 0644)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Manually trigger job execution
|
|
||||||
t.Logf("Running job now...")
|
|
||||||
err = scheduler.RunJobNow(job.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for a bit to ensure job completes (10 seconds should be plenty)
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
|
|
||||||
// Check if webhook was called (it should not have been)
|
|
||||||
webhookMutex.Lock()
|
|
||||||
called := webhookCalled
|
|
||||||
webhookMutex.Unlock()
|
|
||||||
|
|
||||||
assert.False(t, called, "Webhook should not have been called for successful job with NotifyOnSuccess=false")
|
|
||||||
|
|
||||||
// Verify the job actually ran successfully by checking for the file
|
|
||||||
destFile := filepath.Join(destDir, "test.txt")
|
|
||||||
_, err = os.Stat(destFile)
|
|
||||||
assert.NoError(t, err, "The job should have completed and transferred the file")
|
|
||||||
|
|
||||||
// Verify job history has been created and shows completion
|
|
||||||
var histories []db.JobHistory
|
|
||||||
err = database.DB.Where("job_id = ?", job.ID).Find(&histories).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if len(histories) > 0 {
|
|
||||||
t.Logf("Job history found: status=%s", histories[0].Status)
|
|
||||||
assert.Equal(t, "completed", histories[0].Status, "Job should have completed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
err = database.DB.Unscoped().Delete(job).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
err = database.DB.Unscoped().Where("job_id = ?", job.ID).Delete(&db.JobHistory{}).Error
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user