mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 00:50:47 +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 | ||
|
|
aa04c76e1f | ||
|
|
1981764f80 |
@@ -26,6 +26,41 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Needed to get all tags for versioning
|
||||
|
||||
# Set up Node.js for frontend build
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Build frontend assets
|
||||
- name: Build frontend assets
|
||||
run: |
|
||||
node build.js
|
||||
ls -la static/dist/
|
||||
|
||||
# Set version information
|
||||
- name: Set Version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
|
||||
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
else
|
||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
# Set up Docker Buildx for efficient builds
|
||||
- name: Set up Docker Buildx
|
||||
@@ -61,5 +96,11 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||
COMMIT=${{ env.COMMIT }}
|
||||
UID=1000
|
||||
GID=1000
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -27,6 +27,41 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Needed to get all tags for versioning
|
||||
|
||||
# Set up Node.js for frontend build
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
# Install dependencies
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Build frontend assets
|
||||
- name: Build frontend assets
|
||||
run: |
|
||||
node build.js
|
||||
ls -la static/dist/
|
||||
|
||||
# Set version information
|
||||
- name: Set Version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.manual_version }}" != "" ]]; then
|
||||
echo "VERSION=${{ github.event.inputs.manual_version }}" >> $GITHUB_ENV
|
||||
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
else
|
||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")-$(git rev-parse --short HEAD)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
# Set up Docker Buildx for efficient builds
|
||||
- name: Set up Docker Buildx
|
||||
@@ -62,5 +97,11 @@ jobs:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||
COMMIT=${{ env.COMMIT }}
|
||||
UID=1000
|
||||
GID=1000
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -27,6 +27,26 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend assets
|
||||
run: |
|
||||
# Build JavaScript and CSS assets
|
||||
node build.js
|
||||
|
||||
# Ensure the dist directory exists
|
||||
mkdir -p static/dist
|
||||
|
||||
# Verify the build output
|
||||
ls -la static/dist
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -57,23 +77,29 @@ jobs:
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
# Also set build timestamp for versioning
|
||||
echo "BUILD_TIME=$(date -u +'%Y-%m-%d_%H:%M:%S')" >> $GITHUB_ENV
|
||||
echo "COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Build for multiple platforms
|
||||
run: |
|
||||
mkdir -p dist
|
||||
|
||||
# Define common ldflags with version information
|
||||
LDFLAGS="-X github.com/starfleetcptn/gomft/components.AppVersion=$VERSION -X main.Version=$VERSION -X main.BuildTime=$BUILD_TIME -X main.Commit=$COMMIT -X github.com/starfleetcptn/gomft/components.BuildTime=$BUILD_TIME -X github.com/starfleetcptn/gomft/components.Commit=$COMMIT"
|
||||
|
||||
# Linux builds
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-amd64 .
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-arm64 .
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-linux-armv7 .
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-amd64 .
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-arm64 .
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-linux-armv7 .
|
||||
|
||||
# macOS builds
|
||||
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-amd64 .
|
||||
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-darwin-arm64 .
|
||||
GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-amd64 .
|
||||
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-darwin-arm64 .
|
||||
|
||||
# Windows builds
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-amd64.exe .
|
||||
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-X main.version=$VERSION" -o dist/gomft-$VERSION-windows-arm64.exe .
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-amd64.exe .
|
||||
GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o dist/gomft-$VERSION-windows-arm64.exe .
|
||||
|
||||
# Create checksums
|
||||
cd dist
|
||||
@@ -95,4 +121,4 @@ jobs:
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
# The following line is not needed as we set permissions at workflow level
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+3
-1
@@ -79,6 +79,8 @@ backups/
|
||||
/destination/
|
||||
/archive/
|
||||
|
||||
# Ignore the dist directory
|
||||
static/dist/
|
||||
|
||||
# Ignore binaries
|
||||
gomft
|
||||
gomft
|
||||
+78
-6
@@ -1,7 +1,41 @@
|
||||
# Build frontend assets
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all files needed for the build first
|
||||
COPY package.json package-lock.json ./
|
||||
COPY build.js ./
|
||||
COPY static/ ./static/
|
||||
COPY tailwind.config.js ./
|
||||
|
||||
# Debug: Show the contents of build.js
|
||||
RUN echo "Contents of build.js:" && cat build.js
|
||||
|
||||
# Remove the build script from postinstall
|
||||
RUN sed -i 's/"postinstall": "npm run build",//' package.json
|
||||
|
||||
# Install dependencies and build with verbose output
|
||||
RUN npm ci && \
|
||||
echo "Building frontend assets..." && \
|
||||
node build.js && \
|
||||
echo "Build complete. Contents of dist:" && \
|
||||
ls -la static/dist/ && \
|
||||
echo "Sample of app.js:" && \
|
||||
head -n 10 static/dist/app.js && \
|
||||
echo "Sample of app.css:" && \
|
||||
head -n 10 static/dist/app.css
|
||||
|
||||
# Go build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Accept build arguments for version information
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_TIME=unknown
|
||||
ARG COMMIT=unknown
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git build-base
|
||||
|
||||
@@ -12,14 +46,29 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy the rest of the source code
|
||||
# Create static directory structure
|
||||
RUN mkdir -p /app/static/dist
|
||||
|
||||
# Copy built frontend assets from frontend-builder BEFORE copying Go source
|
||||
COPY --from=frontend-builder /app/static/dist/ /app/static/dist/
|
||||
|
||||
# Copy the rest of the static files
|
||||
COPY static/ /app/static/
|
||||
|
||||
# Verify static files are in place before Go build
|
||||
RUN echo "Verifying static files before Go build:" && \
|
||||
ls -la /app/static/dist/
|
||||
|
||||
# Now copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Generate template files from .templ files
|
||||
RUN templ generate
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
|
||||
# Compile the application with version information
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
|
||||
-o /app/gomft
|
||||
|
||||
# Install rclone
|
||||
RUN apk add --no-cache curl unzip && \
|
||||
@@ -34,27 +83,50 @@ RUN apk add --no-cache curl unzip && \
|
||||
# Create a smaller runtime image
|
||||
FROM alpine:3.19
|
||||
|
||||
# Add arguments for UID and GID with defaults
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
ARG USERNAME=gomft
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite bash
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
|
||||
&& apk add --no-cache --virtual .user-deps \
|
||||
shadow curl xz
|
||||
|
||||
# Create user and group with specified IDs
|
||||
RUN addgroup -g ${GID} ${USERNAME} && \
|
||||
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
|
||||
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /app/gomft /app/
|
||||
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
||||
|
||||
# Copy static files and configurations
|
||||
COPY static/ /app/static/
|
||||
# Copy components
|
||||
COPY components/ /app/components/
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create data and backup directories
|
||||
RUN mkdir -p /app/data /app/backups
|
||||
|
||||
# Create a placeholder .env file with proper permissions
|
||||
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
|
||||
|
||||
# Set executable permissions
|
||||
RUN chmod +x /app/gomft
|
||||
|
||||
# Set ownership of application files
|
||||
RUN chown -R ${USERNAME}:${USERNAME} /app
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 8080
|
||||
|
||||
# Use our entrypoint script
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/gomft"]
|
||||
|
||||
@@ -9,6 +9,7 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
> [!WARNING]
|
||||
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -29,6 +30,8 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
|
||||
@@ -49,6 +52,15 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
- Custom HTTP headers
|
||||
- Selectable events (job success, job failure)
|
||||
- Detailed JSON payload with job information
|
||||
- **Multiple Notification Services**: Get job status updates through various notification channels:
|
||||
- Email notifications with configurable SMTP settings
|
||||
- Webhooks with authentication for custom integrations
|
||||
- Pushbullet notifications with optional device targeting
|
||||
- Ntfy.sh (both public and self-hosted) for simple push notifications
|
||||
- Gotify server integration for self-hosted notifications
|
||||
- Pushover notifications with customizable sounds and priorities
|
||||
- Configurable message templates for all notification types
|
||||
- Event-based triggers (job start, completion, errors)
|
||||
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
||||
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
|
||||
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
|
||||
@@ -74,18 +86,30 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
- **Transfer Configurations**: Full control over source and destination connection parameters
|
||||
- **Job Management**: Create, edit, and monitor transfer jobs with scheduling
|
||||
- **Security**: Role-based access control with admin-managed user accounts and secure password management
|
||||
- **Authentication Providers**: Flexible authentication options:
|
||||
- Built-in email/password authentication
|
||||
- Authentik integration for enterprise SSO
|
||||
- OpenID Connect (OIDC) support for standard identity providers
|
||||
- OAuth2 integration for popular providers (Google, GitHub, etc.)
|
||||
- Multiple provider support with fallback options
|
||||
- Automatic user provisioning from external providers
|
||||
- Role mapping from external identity providers
|
||||
- **Password Recovery**: Self-service password reset via email with secure token-based authentication
|
||||
- **User Profile Management**: Personal settings including theme preferences
|
||||
- **Modern UI**: Built with Templ, HTMX and Tailwind CSS for a responsive experience
|
||||
- **Docker Support**: Easy deployment with Docker images and Docker Compose support
|
||||
- **Portable Deployment**: Run on any platform that supports Docker or Go
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.21 or later
|
||||
- rclone installed and configured
|
||||
- SQLite 3
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Standard Installation
|
||||
@@ -116,6 +140,8 @@ docker pull starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
2. Run the container:
|
||||
|
||||
#### Basic run
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
@@ -125,6 +151,42 @@ docker run -d \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Run with specific user ID and group ID (using environment variables)
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-e PUID=$(id -u) \
|
||||
-e PGID=$(id -g) \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Or specify user IDs directly
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-e PUID=1001 \
|
||||
-e PGID=1001 \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Using a .env file for configuration
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-v /path/to/.env:/app/.env \
|
||||
-e PUID=$(id -u) \
|
||||
-e PGID=$(id -g) \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
3. Access the web interface at `http://localhost:8080`
|
||||
|
||||
#### Docker Compose Example
|
||||
@@ -143,19 +205,19 @@ services:
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
- ./.env:/app/.env
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
- SERVER_ADDRESS=:8080
|
||||
- DATA_DIR=/app/data
|
||||
- BACKUP_DIR=/app/backups
|
||||
- JWT_SECRET=change_this_to_a_secure_random_string
|
||||
- BASE_URL=http://localhost:8080
|
||||
# Google OAuth configuration (optional)
|
||||
- GOOGLE_CLIENT_ID=your_google_client_id
|
||||
- GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
# Two-Factor Authentication configuration
|
||||
- TOTP_ENCRYPTION_KEY=your_32_byte_secure_encryption_key
|
||||
# Email configuration
|
||||
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
- EMAIL_ENABLED=true
|
||||
- EMAIL_HOST=smtp.example.com
|
||||
- EMAIL_PORT=587
|
||||
@@ -165,34 +227,14 @@ services:
|
||||
- EMAIL_REQUIRE_AUTH=true
|
||||
- EMAIL_USERNAME=smtp_username
|
||||
- EMAIL_PASSWORD=smtp_password
|
||||
# Logging configuration
|
||||
- LOGS_DIR=/app/data/logs
|
||||
- LOG_MAX_SIZE=10
|
||||
- LOG_MAX_BACKUPS=5
|
||||
- LOG_MAX_AGE=30
|
||||
- LOG_COMPRESS=true
|
||||
- LOG_LEVEL=info
|
||||
# The user directive is no longer needed when using PUID/PGID environment variables
|
||||
```
|
||||
|
||||
Alternatively, you can mount your own .env file to the container:
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
gomft:
|
||||
image: starfleetcptn/gomft:latest
|
||||
container_name: gomft
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
- ./.env:/app/.env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
```
|
||||
|
||||
Save this as `docker-compose.yml` and run:
|
||||
|
||||
```bash
|
||||
@@ -201,11 +243,14 @@ docker-compose up -d
|
||||
|
||||
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
|
||||
|
||||
```
|
||||
```ini
|
||||
# Basic configuration
|
||||
SERVER_ADDRESS=:8080
|
||||
DATA_DIR=/app/data
|
||||
BACKUP_DIR=/app/backups
|
||||
@@ -230,6 +275,10 @@ EMAIL_PASSWORD=smtp_password
|
||||
|
||||
# Two-Factor Authentication configuration
|
||||
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
|
||||
# UserID and GroupID
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
@@ -278,6 +327,8 @@ GoMFT provides configurable logging with rotation support through the following
|
||||
|
||||
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start the server:
|
||||
@@ -321,7 +372,41 @@ Log files contain detailed information about file transfers, job execution, and
|
||||
- Choose notification triggers (job success, job failure, or both)
|
||||
- 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
|
||||
- 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
|
||||
@@ -329,7 +414,7 @@ Log files contain detailed information about file transfers, job execution, and
|
||||
- Delete file metadata records when no longer needed
|
||||
- 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
|
||||
- View system statistics and server information
|
||||
- Create and manage database backups
|
||||
@@ -339,19 +424,57 @@ Log files contain detailed information about file transfers, job execution, and
|
||||
|
||||
### User Management
|
||||
|
||||
GoMFT uses a role-based access control system:
|
||||
GoMFT uses a role-based access control system with flexible authentication options:
|
||||
|
||||
- **Administrators**: Can create and manage users, access all features
|
||||
- **Regular Users**: Can manage transfers and view history
|
||||
|
||||
User management features:
|
||||
- Only administrators can create new user accounts
|
||||
- User passwords are securely hashed with bcrypt
|
||||
- Password history tracking prevents reuse of recent passwords
|
||||
- Account lockout after multiple failed login attempts
|
||||
- Self-service password reset via secure email links
|
||||
- JWT-based authentication with tokens
|
||||
- User theme preference settings (light/dark)
|
||||
#### Authentication Options
|
||||
|
||||
1. **Built-in Authentication**:
|
||||
- Email/password login with secure password hashing
|
||||
- JWT-based session management
|
||||
- Password history tracking
|
||||
- Account lockout protection
|
||||
- Self-service password reset
|
||||
|
||||
2. **External Authentication Providers**:
|
||||
- **Authentik Integration**:
|
||||
- Enterprise-grade SSO capabilities
|
||||
- Automatic user provisioning
|
||||
- Role synchronization
|
||||
- Group mapping support
|
||||
- Secure token exchange
|
||||
|
||||
- **OpenID Connect (OIDC)**:
|
||||
- Standard-compliant identity provider support
|
||||
- Automatic user creation and updates
|
||||
- Role mapping from OIDC claims
|
||||
- Multiple provider support
|
||||
- Secure token validation
|
||||
|
||||
- **OAuth2 Providers**:
|
||||
- Google authentication
|
||||
- GitHub integration
|
||||
- Other OAuth2-compliant providers
|
||||
- Custom provider configuration
|
||||
- Automatic profile synchronization
|
||||
|
||||
3. **Security Features**:
|
||||
- Secure password hashing with bcrypt
|
||||
- JWT-based authentication with tokens
|
||||
- Password history tracking prevents reuse
|
||||
- Account lockout after failed attempts
|
||||
- Two-factor authentication support
|
||||
- Session management and timeout
|
||||
- Secure token storage and handling
|
||||
|
||||
4. **User Profile Management**:
|
||||
- Theme preferences (light/dark mode)
|
||||
- Profile information updates
|
||||
- Password change functionality
|
||||
- Two-factor authentication setup
|
||||
- External account linking
|
||||
|
||||
### Two-Factor Authentication (2FA) Implementation
|
||||
|
||||
@@ -445,13 +568,16 @@ The following fields have been added to the `users` table:
|
||||
- Manual execution
|
||||
- Enable/disable schedules
|
||||
|
||||
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
|
||||
8. **Notification Options**:
|
||||
- **Email Notifications**: Receive job status updates via email
|
||||
- **Webhook Notifications**: Integration with external systems
|
||||
- **Pushbullet**: Push notifications to your devices
|
||||
- **Ntfy**: Simple push notifications via ntfy.sh
|
||||
- **Gotify**: Self-hosted notification server integration
|
||||
- **Pushover**: Professional notification service
|
||||
- Configure event triggers (start, complete, error)
|
||||
- Customize notification message templates
|
||||
- Selective notification based on job status
|
||||
|
||||
### Email Notifications
|
||||
|
||||
@@ -552,6 +678,8 @@ The Admin Tools interface also includes database management capabilities:
|
||||
- View system statistics
|
||||
- Optimize the database with maintenance tools
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
@@ -602,6 +730,8 @@ templ generate
|
||||
air
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
@@ -610,6 +740,8 @@ air
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
GoMFT uses the following directory structure:
|
||||
@@ -630,6 +762,83 @@ volumes:
|
||||
|
||||
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Running as a Non-Root User
|
||||
|
||||
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
|
||||
|
||||
#### Benefits of Running as Non-Root
|
||||
|
||||
- **Improved Security**: Limits the potential damage if the container is compromised
|
||||
- **Better File Permissions**: Files created by the container will match your host user permissions
|
||||
- **Compliance**: Many security policies and best practices require containers to run as non-root
|
||||
|
||||
#### Methods to Run as Non-Root
|
||||
|
||||
1. **Using PUID/PGID environment variables (recommended)**:
|
||||
```bash
|
||||
# Using current user's ID
|
||||
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
|
||||
|
||||
# Or in docker-compose.yml
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
```
|
||||
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
|
||||
|
||||
2. **Using the `--user` flag with Docker run**:
|
||||
```bash
|
||||
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
3. **Using Docker Compose with environment variables for `user` directive**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
image: starfleetcptn/gomft:latest
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
```
|
||||
|
||||
4. **Building a custom image with specified UID/GID**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
```
|
||||
|
||||
#### Environment Variables for User Management
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PUID` | User ID to run as | Built-in user ID (1000) |
|
||||
| `PGID` | Group ID to run as | Built-in group ID (1000) |
|
||||
| `USERNAME` | Username to use | `gomft` |
|
||||
|
||||
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
|
||||
|
||||
#### Volume Permissions
|
||||
|
||||
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
|
||||
|
||||
```bash
|
||||
# Create directories with correct ownership
|
||||
mkdir -p data backups
|
||||
chown -R $(id -u):$(id -g) data backups
|
||||
|
||||
# Or adjust permissions to allow the container user to write
|
||||
mkdir -p data backups
|
||||
chmod -R 777 data backups # Less secure, but easier for testing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,899 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BackupFile struct {
|
||||
Name string
|
||||
Size string
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type LogFile struct {
|
||||
Name string
|
||||
Size string
|
||||
ModTime time.Time
|
||||
Path string
|
||||
}
|
||||
|
||||
type AdminToolsData struct {
|
||||
JobHistoryCount int
|
||||
DatabaseSize string
|
||||
LastBackupTime *time.Time
|
||||
BackupCount int
|
||||
SystemUptime string
|
||||
ActiveJobs int
|
||||
TotalConfigs int
|
||||
TotalJobs int
|
||||
TotalUsers int
|
||||
DatabasePath string
|
||||
BackupPath string
|
||||
MaintenanceMessage string
|
||||
BackupFiles []BackupFile
|
||||
LogFiles []LogFile
|
||||
LogContent string
|
||||
CurrentLogFile string
|
||||
}
|
||||
|
||||
// Dialog component for confirmation dialogs
|
||||
templ Dialog(id string, title string, message string, confirmClass string, confirmText string, formId string, targetAction string) {
|
||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ title }
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4 text-center">
|
||||
<p class="text-secondary-700 dark:text-secondary-300">
|
||||
{ message }
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
|
||||
Cancel
|
||||
</button>
|
||||
if formId != "" {
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
onclick={ submitFormAndHideDialog(formId, id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-target={ targetAction }
|
||||
onclick={ hideDialog(id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
}
|
||||
|
||||
script submitFormAndHideDialog(formId string, dialogId string) {
|
||||
// Use HTMX's API to trigger the request instead of bypassing it
|
||||
htmx.trigger(document.getElementById(formId), 'submit');
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
}
|
||||
|
||||
script showDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Backup dialog component specifically for restore and delete actions
|
||||
templ BackupActionDialog(id string, title string, message string, confirmClass string, confirmText string, action string, backupName string) {
|
||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-5xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ title }
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4 text-center">
|
||||
<p class="text-secondary-700 dark:text-secondary-300">
|
||||
{ message }
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-center space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
|
||||
Cancel
|
||||
</button>
|
||||
if action == "restore" {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-warning"
|
||||
hx-post={ fmt.Sprintf("/admin/restore-database/%s", backupName) }
|
||||
hx-swap="none"
|
||||
onclick={ hideDialog(id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
} else if action == "delete" {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
hx-delete={ fmt.Sprintf("/admin/delete-backup/%s", backupName) }
|
||||
hx-target="closest tr"
|
||||
hx-swap="delete"
|
||||
onclick={ hideDialog(id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||
@LayoutWithContext("Admin Tools", ctx) {
|
||||
<style>
|
||||
/* Custom scrollbar styles - more aggressive */
|
||||
.log-scrollbar {
|
||||
scrollbar-width: thin !important; /* Firefox */
|
||||
scrollbar-color: rgba(0,0,0,0.3) rgba(0,0,0,0.1) !important; /* Firefox */
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar {
|
||||
scrollbar-color: rgba(255,255,255,0.3) rgba(255,255,255,0.1) !important; /* Firefox */
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.1) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3) !important;
|
||||
border-radius: 4px !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
.log-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5) !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255,255,255,0.1) !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3) !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
.dark .log-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.5) !important;
|
||||
border: 2px solid transparent !important;
|
||||
background-clip: content-box !important;
|
||||
}
|
||||
|
||||
/* Force scrollbar to appear */
|
||||
.force-scroll {
|
||||
overflow-y: scroll !important;
|
||||
min-height: 100px !important;
|
||||
}
|
||||
</style>
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-tools mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
Admin Tools
|
||||
</h1>
|
||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<span id="current-date" class="font-medium"></span>
|
||||
<script>
|
||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if data.MaintenanceMessage != "" {
|
||||
<div class="mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-4 rounded">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
{ data.MaintenanceMessage }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- System Overview -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||
<i class="fas fa-chart-line mr-2 text-primary-500"></i>
|
||||
System Overview
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-database text-blue-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
Database Size
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ data.DatabaseSize }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-history text-green-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
Job History Records
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.JobHistoryCount) }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-clock text-purple-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
System Uptime
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ data.SystemUptime }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-tasks text-orange-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
Active Jobs
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.ActiveJobs) }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Tools Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Backup & Restore -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-save mr-2 text-primary-500"></i>
|
||||
Backup & Restore
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between text-sm text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
if data.LastBackupTime != nil {
|
||||
<span>Last backup: { data.LastBackupTime.Format("Jan 02, 2006 15:04:05") }</span>
|
||||
} else {
|
||||
<span>Last backup: Never</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<button id="backup-form"
|
||||
hx-post="/admin/backup-database"
|
||||
hx-swap="none"
|
||||
hx-indicator="#backup-indicator"
|
||||
hx-on::after-request="htmx.trigger('#refresh-backups-trigger', 'click')"
|
||||
class="btn-primary w-full flex items-center justify-center">
|
||||
<i class="fas fa-database mr-2"></i>
|
||||
<span>Backup Database</span>
|
||||
<div id="backup-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Hidden refresh trigger -->
|
||||
<button id="refresh-backups-trigger"
|
||||
class="hidden"
|
||||
hx-get="/admin/refresh-backups"
|
||||
hx-target="#backups-container"></button>
|
||||
|
||||
<form id="export-configs-form" action="/admin/export-configs" method="GET">
|
||||
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||
<i class="fas fa-file-export mr-2"></i>
|
||||
<span>Export All Configurations</span>
|
||||
<div id="export-configs-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form id="export-jobs-form" action="/admin/export-jobs" method="GET">
|
||||
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||
<i class="fas fa-file-export mr-2"></i>
|
||||
<span>Export All Jobs</span>
|
||||
<div id="export-jobs-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
|
||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">Restore Database</h4>
|
||||
<form id="restore-form" hx-post="/admin/restore-database" hx-encoding="multipart/form-data" hx-swap="none" hx-indicator="#restore-indicator">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-grow">
|
||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Select Backup File
|
||||
</label>
|
||||
<input type="file" name="backup_file" class="block w-full text-sm text-secondary-500 dark:text-secondary-400
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-medium
|
||||
file:bg-primary-50 file:text-primary-700
|
||||
dark:file:bg-primary-900 dark:file:text-primary-300
|
||||
hover:file:bg-primary-100 dark:hover:file:bg-primary-800
|
||||
focus:outline-none" required />
|
||||
</div>
|
||||
<button type="submit" class="btn-warning flex-shrink-0 flex items-center justify-center h-10">
|
||||
<div class="flex items-center justify-center">
|
||||
<i class="fas fa-upload mr-1"></i>
|
||||
<span>Restore</span>
|
||||
<div id="restore-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Warning: This will replace your current database. Make sure to backup first!
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Tools -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-wrench mr-2 text-primary-500"></i>
|
||||
Maintenance Tools
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body space-y-4">
|
||||
<!-- Clear Job History Button and Dialog -->
|
||||
@Dialog("clear-job-dialog", "Clear Job History", "Are you sure you want to clear all job history? This cannot be undone.", "btn-danger", "Clear History", "purge-form", "")
|
||||
<form id="purge-form" hx-post="/admin/clear-job-history" hx-swap="none" hx-indicator="#clear-history-indicator">
|
||||
<button type="button" class="btn-danger w-full flex items-center justify-center" onclick={ showDialog("clear-job-dialog") }>
|
||||
<i class="fas fa-trash-alt mr-2"></i>
|
||||
<span>Clear Job History</span>
|
||||
<div id="clear-history-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
|
||||
<form id="vacuum-form" hx-post="/admin/vacuum-database" hx-swap="none" hx-indicator="#vacuum-indicator">
|
||||
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||
<i class="fas fa-compress-alt mr-2"></i>
|
||||
<span>Optimize Database</span>
|
||||
<div id="vacuum-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
<p class="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
|
||||
Runs VACUUM to optimize the database and reclaim unused space.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Backups -->
|
||||
<div id="backups-container" class="mt-8">
|
||||
@BackupsList(data)
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="mt-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-info-circle mr-2 text-primary-500"></i>
|
||||
System Information
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Database Path</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
|
||||
{ data.DatabasePath }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Backup Directory</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
|
||||
{ data.BackupPath }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Users</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.TotalUsers) }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Configurations</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.TotalConfigs) }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Jobs</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.TotalJobs) }
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Viewer -->
|
||||
<div id="logs-container" class="mt-8">
|
||||
@AdminLogViewer(data)
|
||||
</div>
|
||||
|
||||
<!-- Webhook Documentation -->
|
||||
<div class="mt-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-bell mr-2 text-primary-500"></i>
|
||||
Webhook Notifications
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
GoMFT can send webhook notifications when jobs run. You can configure webhooks
|
||||
for individual jobs in the job edit form. Below is the format of the webhook payload:
|
||||
</p>
|
||||
|
||||
<div class="bg-secondary-50 dark:bg-secondary-900 p-4 rounded-lg overflow-auto font-mono text-sm">
|
||||
<pre>{
|
||||
"event_type": "job_execution",
|
||||
"job_id": 123,
|
||||
"job_name": "Daily Backup",
|
||||
"config_id": 456,
|
||||
"config_name": "Backup Config",
|
||||
"status": "completed",
|
||||
"start_time": "2023-06-18T15:30:45Z",
|
||||
"end_time": "2023-06-18T15:35:12Z",
|
||||
"duration_seconds": 267,
|
||||
"history_id": 789,
|
||||
"bytes_transferred": 1048576,
|
||||
"files_transferred": 5,
|
||||
"source": {
|
||||
"type": "local",
|
||||
"path": "/path/to/source"
|
||||
},
|
||||
"destination": {
|
||||
"type": "s3",
|
||||
"path": "bucket/path"
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mt-6 mb-2">Authentication</h4>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
When configuring a webhook, you can optionally provide a secret key. This will be used to sign
|
||||
the webhook payload with HMAC-SHA256. The signature is provided in the <code>X-Hub-Signature-256</code> header.
|
||||
</p>
|
||||
|
||||
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mt-6 mb-2">HTTP Request Details</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left text-sm font-medium text-secondary-500 dark:text-secondary-400 pb-2">Property</th>
|
||||
<th class="text-left text-sm font-medium text-secondary-500 dark:text-secondary-400 pb-2">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tr>
|
||||
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">Method</td>
|
||||
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">POST</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">Content-Type</td>
|
||||
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">application/json</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">User-Agent</td>
|
||||
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">GoMFT-Webhook/1.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">X-Hub-Signature-256</td>
|
||||
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">HMAC SHA256 signature (if secret configured)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-sm text-secondary-900 dark:text-secondary-100 font-medium">Custom Headers</td>
|
||||
<td class="py-2 text-sm text-secondary-600 dark:text-secondary-400">Any additional headers specified in the job configuration</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// BackupsList is a separate component for the backups list that can be refreshed via HTMX
|
||||
templ BackupsList(data AdminToolsData) {
|
||||
if len(data.BackupFiles) > 0 {
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||
Available Backups
|
||||
</h3>
|
||||
<button
|
||||
hx-get="/admin/refresh-backups"
|
||||
hx-target="#backups-container"
|
||||
hx-indicator="#refresh-backups-indicator"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
title="Refresh backups list">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="bg-secondary-50 dark:bg-secondary-900 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead>
|
||||
<tr class="bg-secondary-100 dark:bg-secondary-800">
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Name</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Size</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Date</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
for _, backup := range data.BackupFiles {
|
||||
<tr class="hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-colors">
|
||||
<td class="px-4 py-2 text-sm text-secondary-900 dark:text-secondary-100">{ backup.Name }</td>
|
||||
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.Size }</td>
|
||||
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.ModTime.Format("Jan 02, 2006 15:04:05") }</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/admin/download-backup/%s", backup.Name)) }
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mr-2"
|
||||
title="Download backup">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
|
||||
<!-- Restore Dialog -->
|
||||
@BackupActionDialog(
|
||||
fmt.Sprintf("restore-dialog-%s", backup.Name),
|
||||
"RESTORE BACKUP",
|
||||
fmt.Sprintf("Are you sure you want to restore the backup '%s'? This will replace your current database.", backup.Name),
|
||||
"btn-warning",
|
||||
"Restore",
|
||||
"restore",
|
||||
backup.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
class="text-yellow-600 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 mr-2"
|
||||
title="Restore this backup"
|
||||
onclick={ showDialog(fmt.Sprintf("restore-dialog-%s", backup.Name)) }>
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
@BackupActionDialog(
|
||||
fmt.Sprintf("delete-dialog-%s", backup.Name),
|
||||
"DELETE BACKUP",
|
||||
fmt.Sprintf("Are you sure you want to delete the backup '%s'? This cannot be undone.", backup.Name),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
backup.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete backup"
|
||||
onclick={ showDialog(fmt.Sprintf("delete-dialog-%s", backup.Name)) }>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||
Available Backups
|
||||
</h3>
|
||||
<button
|
||||
hx-get="/admin/refresh-backups"
|
||||
hx-target="#backups-container"
|
||||
hx-indicator="#refresh-backups-indicator"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
title="Refresh backups list">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center py-6">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-secondary-100 dark:bg-secondary-800 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-secondary-900 dark:text-secondary-100">No Backups Available</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Create a backup using the "Backup Database" button.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Add this new template after other admin tool templates
|
||||
templ AdminLogViewer(data AdminToolsData) {
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-secondary-900 dark:text-secondary-100 flex items-center">
|
||||
<i class="fas fa-file-alt mr-2"></i> Log Files
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div class="lg:col-span-1 border-r border-secondary-200 dark:border-secondary-700 pr-4">
|
||||
<h4 class="text-lg font-medium mb-2 text-secondary-900 dark:text-secondary-100">Available Logs</h4>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto pr-2 log-scrollbar">
|
||||
if len(data.LogFiles) == 0 {
|
||||
<div class="text-secondary-600 dark:text-secondary-400 italic">
|
||||
No log files found
|
||||
</div>
|
||||
} else {
|
||||
<div class="flex flex-col space-y-1">
|
||||
for _, logFile := range data.LogFiles {
|
||||
<button
|
||||
class={
|
||||
"text-left px-3 py-2 rounded transition-colors flex justify-between items-center",
|
||||
templ.KV("bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400", logFile.Name == data.CurrentLogFile),
|
||||
templ.KV("hover:bg-secondary-50 dark:hover:bg-secondary-700/50 text-secondary-700 dark:text-secondary-300", logFile.Name != data.CurrentLogFile)
|
||||
}
|
||||
hx-get={ fmt.Sprintf("/admin/logs/view/%s", logFile.Name) }
|
||||
hx-target="#log-content"
|
||||
hx-indicator="#log-loading"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-file-alt mr-2"></i>
|
||||
{ logFile.Name }
|
||||
</span>
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">{ logFile.Size }</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-4 flex justify-between">
|
||||
<button
|
||||
class="btn-secondary btn-sm"
|
||||
hx-get="/admin/logs/refresh"
|
||||
hx-target="#logs-container"
|
||||
hx-indicator="#refresh-logs-indicator"
|
||||
>
|
||||
<span id="refresh-logs-indicator" class="htmx-indicator">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3 pl-0 lg:pl-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
if data.CurrentLogFile != "" {
|
||||
Log: { data.CurrentLogFile }
|
||||
} else {
|
||||
Select a log file
|
||||
}
|
||||
</h4>
|
||||
if data.CurrentLogFile != "" {
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
class="btn-secondary btn-sm"
|
||||
hx-get={ fmt.Sprintf("/admin/logs/download/%s", data.CurrentLogFile) }
|
||||
>
|
||||
<i class="fas fa-download mr-1"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@AdminLogContent(data)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// AdminLogContent template for log view
|
||||
templ AdminLogContent(data AdminToolsData) {
|
||||
<div id="log-content" class="relative">
|
||||
<div id="log-loading" class="htmx-indicator absolute inset-0 bg-white/75 dark:bg-secondary-800/75 flex items-center justify-center">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-2xl"></i>
|
||||
</div>
|
||||
if data.CurrentLogFile == "" {
|
||||
<div class="border border-secondary-200 dark:border-secondary-700 rounded p-4 text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-900/30 text-center h-96 flex items-center justify-center">
|
||||
<div>
|
||||
<i class="fas fa-file-alt text-4xl mb-2"></i>
|
||||
<p>Select a log file to view its contents</p>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Fixed height log content container with guaranteed scrollbars -->
|
||||
<div class="log-content-container" style="height: 400px; border: 1px solid #ccc; border-radius: 0.375rem; position: relative;">
|
||||
<!-- Standard scrollable div -->
|
||||
<div id="log-content-text" class="p-4 h-full overflow-y-scroll bg-secondary-50 dark:bg-secondary-900/30 text-secondary-800 dark:text-secondary-200 text-sm font-mono whitespace-pre-wrap" style="scrollbar-width: thin;">
|
||||
{ data.LogContent }
|
||||
</div>
|
||||
|
||||
<!-- Custom scrollbar -->
|
||||
<div class="custom-scrollbar dark:bg-white dark:bg-opacity-10" style="position: absolute; right: 0; top: 0; width: 12px; height: 100%; background-color: rgba(0,0,0,0.05); border-radius: 0 0.375rem 0.375rem 0;">
|
||||
<div class="scrollbar-thumb dark:bg-opacity-30 dark:bg-white" style="position: absolute; right: 0; width: 12px; background-color: rgba(0,0,0,0.3); border-radius: 6px; cursor: pointer; min-height: 40px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Custom scrollbar implementation
|
||||
(function() {
|
||||
const content = document.getElementById('log-content-text');
|
||||
const scrollThumb = document.querySelector('.scrollbar-thumb');
|
||||
|
||||
// Initial position
|
||||
updateScrollThumb();
|
||||
|
||||
// Update scrollbar position when content is scrolled
|
||||
content.addEventListener('scroll', updateScrollThumb);
|
||||
|
||||
function updateScrollThumb() {
|
||||
const scrollPercentage = content.scrollTop / (content.scrollHeight - content.clientHeight);
|
||||
const thumbHeight = Math.max(40, (content.clientHeight / content.scrollHeight) * content.clientHeight);
|
||||
const thumbTop = scrollPercentage * (content.clientHeight - thumbHeight);
|
||||
|
||||
scrollThumb.style.height = thumbHeight + 'px';
|
||||
scrollThumb.style.top = thumbTop + 'px';
|
||||
}
|
||||
|
||||
// Dragging the scrollbar
|
||||
let isDragging = false;
|
||||
let startY, startTop;
|
||||
|
||||
scrollThumb.addEventListener('mousedown', function(e) {
|
||||
isDragging = true;
|
||||
startY = e.clientY;
|
||||
startTop = parseInt(scrollThumb.style.top) || 0;
|
||||
document.body.style.userSelect = 'none'; // Prevent text selection during drag
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaY = e.clientY - startY;
|
||||
const newTop = Math.max(0, Math.min(content.clientHeight - scrollThumb.offsetHeight, startTop + deltaY));
|
||||
scrollThumb.style.top = newTop + 'px';
|
||||
|
||||
// Update scroll position
|
||||
const scrollPercentage = newTop / (content.clientHeight - scrollThumb.offsetHeight);
|
||||
content.scrollTop = scrollPercentage * (content.scrollHeight - content.clientHeight);
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function() {
|
||||
isDragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
content.scrollTop = content.scrollHeight;
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -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/destination"
|
||||
"github.com/starfleetcptn/gomft/components/providers/common"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ConfigFormData struct {
|
||||
@@ -47,7 +46,7 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
sourceTeamDrive := ""
|
||||
// Google Photos source fields
|
||||
sourceReadOnly := false
|
||||
sourceStartYear, _ := strconv.Atoi(getCurrentYear())
|
||||
sourceStartYear := 2025
|
||||
sourceIncludeArchived := false
|
||||
|
||||
filePattern := ""
|
||||
@@ -75,7 +74,7 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
destTeamDrive := ""
|
||||
// Google Photos destination fields
|
||||
destReadOnly := false
|
||||
destStartYear, _ := strconv.Atoi(getCurrentYear()) // Default to current year int
|
||||
destStartYear := 2025
|
||||
destIncludeArchived := false
|
||||
|
||||
archivePath := ""
|
||||
@@ -84,6 +83,8 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
skipProcessedFiles := true
|
||||
maxConcurrentTransfers := 4
|
||||
rcloneFlags := ""
|
||||
commandId := uint(1) // Default to 'copy' command
|
||||
commandFlags := ""
|
||||
useBuiltinAuthSource := true
|
||||
useBuiltinAuthDest := true
|
||||
|
||||
@@ -117,7 +118,7 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
if config.SourceReadOnly != nil {
|
||||
sourceReadOnly = *config.SourceReadOnly
|
||||
}
|
||||
sourceStartYear = config.SourceStartYear
|
||||
// sourceStartYear = config.SourceStartYear
|
||||
if config.SourceIncludeArchived != nil {
|
||||
sourceIncludeArchived = *config.SourceIncludeArchived
|
||||
}
|
||||
@@ -152,7 +153,7 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
if config.DestReadOnly != nil {
|
||||
destReadOnly = *config.DestReadOnly
|
||||
}
|
||||
destStartYear = config.DestStartYear
|
||||
// destStartYear = config.DestStartYear
|
||||
if config.DestIncludeArchived != nil {
|
||||
destIncludeArchived = *config.DestIncludeArchived
|
||||
}
|
||||
@@ -163,6 +164,8 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
skipProcessedFiles = config.GetSkipProcessedFiles()
|
||||
maxConcurrentTransfers = config.MaxConcurrentTransfers
|
||||
rcloneFlags = config.RcloneFlags
|
||||
commandId = config.CommandID
|
||||
commandFlags = config.CommandFlags
|
||||
if config.UseBuiltinAuthSource != nil {
|
||||
useBuiltinAuthSource = *config.UseBuiltinAuthSource
|
||||
} else if sourceClientId != "" || sourceClientSecret != "" {
|
||||
@@ -238,12 +241,17 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
skipProcessedFiles: %v,
|
||||
maxConcurrentTransfers: %d,
|
||||
rcloneFlags: '%s',
|
||||
commandId: %d,
|
||||
commandFlags: '%s',
|
||||
|
||||
// Path validation states
|
||||
sourcePathValid: null,
|
||||
sourcePathError: '',
|
||||
destPathValid: null,
|
||||
destPathError: '',
|
||||
|
||||
// Command configuration
|
||||
requiresDestination: true,
|
||||
|
||||
// Methods for path validation
|
||||
checkPath(path, type) {
|
||||
@@ -263,6 +271,68 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
this[type + 'PathValid'] = false;
|
||||
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,
|
||||
@@ -275,171 +345,254 @@ func getInitialData(config *db.TransferConfig) string {
|
||||
destClientId, destClientSecret, destDriveId, destTeamDrive,
|
||||
destReadOnly, destStartYear, destIncludeArchived,
|
||||
useBuiltinAuthSource, useBuiltinAuthDest,
|
||||
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags)
|
||||
archivePath, archiveEnabled, deleteAfterTransfer, skipProcessedFiles, maxConcurrentTransfers, rcloneFlags,
|
||||
commandId, commandFlags)
|
||||
}
|
||||
|
||||
templ ConfigForm(ctx context.Context, data ConfigFormData) {
|
||||
@LayoutWithContext(getConfigFormTitle(data.IsNew), ctx) {
|
||||
<div class="flex flex-col items-center justify-center min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-3xl w-full bg-white dark:bg-secondary-800 rounded-lg shadow-md p-8 border border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="p-4 bg-primary-100 dark:bg-primary-900 rounded-full">
|
||||
<i class="fas fa-cog text-primary-500 dark:text-primary-400 text-2xl"></i>
|
||||
<!-- Main Content -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 md:p-8">
|
||||
<!-- Form Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
|
||||
<i class="fas fa-cog text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{ getConfigFormTitle(data.IsNew) }
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure your file transfer settings using the form below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Form -->
|
||||
<form
|
||||
class="space-y-6"
|
||||
if data.IsNew {
|
||||
hx-post="/configs"
|
||||
} else {
|
||||
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
|
||||
}
|
||||
hx-target="body"
|
||||
hx-redirect="/configs"
|
||||
hx-boost="true"
|
||||
x-data={ getInitialData(data.Config) }
|
||||
x-init="$nextTick(() => {
|
||||
// Ensure initial form state displays correctly on load
|
||||
sourceType = sourceType || 'local';
|
||||
destinationType = destinationType || 'local';
|
||||
sourcePort = sourcePort || 22;
|
||||
destPort = destPort || 22;
|
||||
// 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>
|
||||
|
||||
<h1 class="mt-2 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
|
||||
{ getConfigFormTitle(data.IsNew) }
|
||||
</h1>
|
||||
<p class="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Set up your file transfer configuration
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="space-y-8"
|
||||
if data.IsNew {
|
||||
hx-post="/configs"
|
||||
} else {
|
||||
hx-post={ fmt.Sprintf("/configs/%d", data.Config.ID) }
|
||||
}
|
||||
hx-target="body"
|
||||
hx-redirect="/configs"
|
||||
hx-boost="true"
|
||||
x-data={ getInitialData(data.Config) }
|
||||
x-init="$nextTick(() => {
|
||||
// Ensure initial form state displays correctly on load
|
||||
sourceType = sourceType || 'local';
|
||||
destinationType = destinationType || 'local';
|
||||
sourcePort = sourcePort || 22;
|
||||
destPort = destPort || 22;
|
||||
})"
|
||||
>
|
||||
|
||||
<!-- Name field -->
|
||||
@common.NameField()
|
||||
|
||||
<!-- Source section -->
|
||||
@common.SourceSelection()
|
||||
|
||||
<!-- Source type specific forms -->
|
||||
<template x-if="sourceType === 'local'">
|
||||
@source.LocalSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'sftp'">
|
||||
@source.SFTPSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'ftp'">
|
||||
@source.FTPSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 's3'">
|
||||
@source.S3SourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'minio'">
|
||||
@source.MinIOSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'smb'">
|
||||
@source.SMBSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'webdav'">
|
||||
@source.WebDAVSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'nextcloud'">
|
||||
@source.NextCloudSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'gdrive'">
|
||||
@source.GoogleDriveSourceForm()
|
||||
</template>
|
||||
|
||||
<template x-if="sourceType === 'gphotos'">
|
||||
@source.GooglePhotosSourceForm()
|
||||
</template>
|
||||
|
||||
<!-- File pattern fields -->
|
||||
@common.FilePatternFields()
|
||||
|
||||
<!-- Destination section -->
|
||||
@common.DestinationSelection()
|
||||
|
||||
<!-- Destination type specific forms -->
|
||||
<template x-if="destinationType === 'local'">
|
||||
@destination.LocalDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'sftp'">
|
||||
@destination.SFTPDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'ftp'">
|
||||
@destination.FTPDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 's3'">
|
||||
@destination.S3DestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'minio'">
|
||||
@destination.MinIODestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'smb'">
|
||||
@destination.SMBDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'nextcloud'">
|
||||
@destination.NextCloudDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'webdav'">
|
||||
@destination.WebDAVDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'gdrive'">
|
||||
@destination.GoogleDriveDestinationForm()
|
||||
</template>
|
||||
|
||||
<template x-if="destinationType === 'gphotos'">
|
||||
@destination.GooglePhotosDestinationForm()
|
||||
</template>
|
||||
|
||||
<!-- Archive options -->
|
||||
@common.ArchiveOptions()
|
||||
|
||||
<!-- Rclone flags -->
|
||||
@common.RcloneFlags()
|
||||
|
||||
<!-- Form actions -->
|
||||
<div class="flex justify-end mt-8">
|
||||
<a href="/configs"
|
||||
class="btn-secondary mr-4">
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
if data.IsNew {
|
||||
Create Configuration
|
||||
} else {
|
||||
Save Changes
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<p>Configure your file transfer settings carefully for optimal performance</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
+289
-122
@@ -6,55 +6,68 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// Dialog component for confirmation dialogs
|
||||
// Dialog component for confirmation dialogs using Flowbite modal
|
||||
templ ConfigDialog(id string, title string, message string, confirmClass string, confirmText string, action string, configID uint, configName string) {
|
||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<!-- Backdrop -->
|
||||
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||
<!-- Modal content -->
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
if action == "delete" {
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-circle text-yellow-400 text-3xl mb-4"></i>
|
||||
}
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
|
||||
hx-target="closest li"
|
||||
hx-swap="delete"
|
||||
data-config-name={ configName }
|
||||
data-config-id={ fmt.Sprint(configID) }
|
||||
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
|
||||
onclick={ triggerConfigDelete(id, configID, configName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ title }
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4 text-center">
|
||||
<p class="text-secondary-700 dark:text-secondary-300">
|
||||
{ message }
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick={ hideConfigDialog(id) }>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-delete={ fmt.Sprintf("/configs/%d", configID) }
|
||||
hx-target="closest li"
|
||||
hx-swap="delete"
|
||||
data-config-name={ configName }
|
||||
data-config-id={ fmt.Sprint(configID) }
|
||||
id={ fmt.Sprintf("delete-config-btn-%d", configID) }
|
||||
onclick={ triggerConfigDelete(id, configID, configName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideConfigDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
script closeModal(id string) {
|
||||
const modal = document.getElementById(id);
|
||||
const backdrop = document.getElementById(id + '-backdrop');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.remove();
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
script showConfigDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
script showModal(id string) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
script triggerConfigDelete(dialogId string, configID uint, configName string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
document.getElementById(dialogId).classList.remove("flex");
|
||||
|
||||
// Store data in a way that's accessible to event handlers
|
||||
window.lastDeletedConfig = {
|
||||
@@ -75,38 +88,82 @@ type ConfigsData struct {
|
||||
|
||||
templ Configs(ctx context.Context, data ConfigsData) {
|
||||
@LayoutWithContext("Transfer Configurations", ctx) {
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
<script>
|
||||
// Debug notification system
|
||||
console.log("Configs template loaded, setting up notification system");
|
||||
|
||||
// Create a global notyf instance if it doesn't exist yet
|
||||
if (!window.notyf) {
|
||||
window.notyf = new Notyf({
|
||||
duration: 3000,
|
||||
position: {
|
||||
x: 'right',
|
||||
y: 'top',
|
||||
},
|
||||
types: [
|
||||
{
|
||||
type: 'success',
|
||||
background: '#38c172',
|
||||
icon: {
|
||||
className: 'fas fa-check-circle',
|
||||
tagName: 'i'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'error',
|
||||
background: '#e3342f',
|
||||
icon: {
|
||||
className: 'fas fa-exclamation-circle',
|
||||
tagName: 'i'
|
||||
}
|
||||
}
|
||||
]
|
||||
// Function to create and show a toast
|
||||
function showToast(message, type) {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass, bgColorClass, textColorClass;
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||
textColorClass = 'text-green-500 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||
textColorClass = 'text-red-500 dark:text-red-200';
|
||||
} else {
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Set inner HTML with appropriate icon and message
|
||||
toast.innerHTML = `
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||
${type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>'}
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay to ensure the DOM has updated
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
console.log("Notyf initialized:", window.notyf);
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Show status messages based on URL parameters
|
||||
@@ -122,12 +179,23 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
if (errorDetails) {
|
||||
message += ": " + errorDetails;
|
||||
}
|
||||
window.notyf.error(message);
|
||||
showToast(message, 'error');
|
||||
}
|
||||
|
||||
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
|
||||
@@ -143,6 +211,18 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
// This is definitely a delete request - store this information
|
||||
window.isConfigDeleteRequest = true;
|
||||
}
|
||||
|
||||
// Check if this is a duplication request
|
||||
if (path && method === 'POST' && path.match(/^\/configs\/\d+\/duplicate$/)) {
|
||||
window.isConfigDuplicateRequest = true;
|
||||
|
||||
// Store the config ID for reference
|
||||
const configId = path.match(/^\/configs\/(\d+)\/duplicate$/)[1];
|
||||
const configButton = document.querySelector(`button[hx-post="/configs/${configId}/duplicate"]`);
|
||||
if (configButton) {
|
||||
window.duplicatingConfigName = configButton.closest('li').querySelector('.text-blue-600').textContent.trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Track HTMX after-request events for config deletion
|
||||
@@ -175,13 +255,29 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
configName = window.lastDeletedConfig.name;
|
||||
}
|
||||
|
||||
window.notyf.success(`Configuration "${configName}" deleted successfully`);
|
||||
showToast(`Configuration "${configName}" deleted successfully`, 'success');
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingConfig = false;
|
||||
window.isConfigDeleteRequest = false;
|
||||
window.lastDeletedConfig = null;
|
||||
}
|
||||
|
||||
// Check if this is a duplication request
|
||||
const isDuplicateRequest = window.isConfigDuplicateRequest &&
|
||||
event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
|
||||
|
||||
// If this is a successful duplication request, show notification
|
||||
if (isDuplicateRequest && event.detail.successful) {
|
||||
const configName = window.duplicatingConfigName || "configuration";
|
||||
showToast(`Configuration "${configName}" duplicated successfully`, 'success');
|
||||
|
||||
// Clear flags
|
||||
window.isConfigDuplicateRequest = false;
|
||||
window.duplicatingConfigName = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Track HTMX error events for config deletion
|
||||
@@ -218,69 +314,97 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
errorMsg = `Error: ${error.error}`;
|
||||
}
|
||||
|
||||
window.notyf.error(errorMsg);
|
||||
showToast(errorMsg, 'error');
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingConfig = false;
|
||||
window.isConfigDeleteRequest = false;
|
||||
window.lastDeletedConfig = null;
|
||||
}
|
||||
|
||||
// Check if this is a duplication request
|
||||
const isDuplicateRequest = window.isConfigDuplicateRequest &&
|
||||
event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+\/duplicate$/);
|
||||
|
||||
if (isDuplicateRequest) {
|
||||
const configName = window.duplicatingConfigName || "configuration";
|
||||
|
||||
let errorMsg = `Failed to duplicate configuration "${configName}"`;
|
||||
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
try {
|
||||
const error = JSON.parse(event.detail.xhr.responseText);
|
||||
errorMsg = `Error: ${error.error}`;
|
||||
} catch (e) {
|
||||
// If not JSON, use the response text directly
|
||||
if (event.detail.xhr.responseText.trim()) {
|
||||
errorMsg = event.detail.xhr.responseText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showToast(errorMsg, 'error');
|
||||
|
||||
// Clear flags
|
||||
window.isConfigDuplicateRequest = false;
|
||||
window.duplicatingConfigName = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<div id="configs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="configs-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-exchange-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Transfer Configurations
|
||||
</h1>
|
||||
<a href="/configs/new" class="btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<a href="/configs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
New Configuration
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(data.Configs) == 0 {
|
||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No configurations</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer configuration.</p>
|
||||
<div class="mt-6">
|
||||
<a href="/configs/new" class="btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create First Configuration
|
||||
</a>
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-exchange-alt text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No configurations</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new transfer configuration.</p>
|
||||
<a href="/configs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create First Configuration
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, config := range data.Configs {
|
||||
<li>
|
||||
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
{ config.Name }
|
||||
</p>
|
||||
|
||||
<!-- Google Drive Authentication Badge -->
|
||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
|
||||
<i class="fas fa-exclamation-triangle mr-1 flex items-center"></i>
|
||||
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
|
||||
Authentication Required
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- Google Drive Authentication Status Indicator -->
|
||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
|
||||
<i class="fas fa-check-circle mr-1 flex items-center"></i>
|
||||
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
|
||||
Authenticated
|
||||
</span>
|
||||
}
|
||||
@@ -288,22 +412,31 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<!-- Google Drive Authentication Button -->
|
||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="btn-warning btn-sm">
|
||||
<i class="fab fa-google mr-1"></i>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800">
|
||||
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
|
||||
Authenticate
|
||||
</a>
|
||||
}
|
||||
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
|
||||
<i class="fas fa-edit mr-1"></i>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<!-- Duplicate config button -->
|
||||
<button
|
||||
type="button"
|
||||
hx-post={ fmt.Sprintf("/configs/%d/duplicate", config.ID) }
|
||||
hx-target="body"
|
||||
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800">
|
||||
<i class="fas fa-clone w-3.5 h-3.5 mr-1.5"></i>
|
||||
Duplicate
|
||||
</button>
|
||||
<!-- Add delete dialog for each configuration -->
|
||||
@ConfigDialog(
|
||||
fmt.Sprintf("delete-config-dialog-%d", config.ID),
|
||||
"Delete Configuration",
|
||||
fmt.Sprintf("Are you sure you want to delete the configuration '%s'? This cannot be undone.", config.Name),
|
||||
"btn-danger",
|
||||
"text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center",
|
||||
"Delete",
|
||||
"delete",
|
||||
config.ID,
|
||||
@@ -311,26 +444,26 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showConfigDialog(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||
class="btn-danger btn-sm">
|
||||
<i class="fas fa-trash-alt mr-1"></i>
|
||||
onclick={ showModal(fmt.Sprintf("delete-config-dialog-%d", config.ID)) }
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800">
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<div class="mt-3 sm:flex sm:justify-between">
|
||||
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-upload w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
Source: { config.SourceType }: { config.SourcePath }
|
||||
</p>
|
||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-download flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<p class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-download w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
Destination: { config.DestinationType }: { config.DestinationPath }
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
<p>
|
||||
Updated: { config.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||
</p>
|
||||
@@ -345,22 +478,56 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
||||
Configurations define how files are transferred between systems
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Google Drive Auth Help -->
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fab fa-google mr-1 text-blue-500 inline-flex items-center"></i>
|
||||
Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.
|
||||
</p>
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start mb-2">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Configurations define how files are transferred between systems</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fab fa-google-drive w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-clone w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">When duplicating configurations, you may need to re-enter sensitive credentials for security reasons. Make sure to edit duplicated configurations to add any required credentials.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('configs-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('configs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
+184
-92
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DashboardData struct {
|
||||
@@ -13,19 +15,44 @@ type DashboardData struct {
|
||||
CompletedToday int
|
||||
FailedTransfers int
|
||||
Configs map[uint]db.TransferConfig
|
||||
RcloneVersion string
|
||||
}
|
||||
|
||||
// GetRcloneVersion executes the rclone --version command and returns the version string
|
||||
func GetRcloneVersion() string {
|
||||
cmd := exec.Command("rclone", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Parse the version from output (typically first line contains "rclone v1.XX.X")
|
||||
outputStr := string(output)
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
if len(lines) > 0 {
|
||||
parts := strings.Split(lines[0], " ")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "v") {
|
||||
return part
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
@LayoutWithContext("Dashboard", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-tachometer-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Dashboard
|
||||
</h1>
|
||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<span id="current-date" class="font-medium"></span>
|
||||
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
|
||||
<script>
|
||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
</script>
|
||||
@@ -33,154 +60,199 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-6 flex items-center">
|
||||
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
|
||||
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
<!-- Active Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
|
||||
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
|
||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-6 flex items-center">
|
||||
<div class="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
|
||||
<!-- Completed Today -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
|
||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-6 flex items-center">
|
||||
<div class="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
|
||||
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
|
||||
<!-- Failed Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
|
||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||
<!-- Main Content Area -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<!-- Recent Jobs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Recent Jobs
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-4">
|
||||
if len(data.RecentJobs) == 0 {
|
||||
<div class="py-8 text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
|
||||
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
|
||||
<!-- Empty state with Flowbite styling -->
|
||||
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
|
||||
</div>
|
||||
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
|
||||
<a href="/jobs/new" class="mt-4 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<span>Create your first job</span>
|
||||
<i class="fas fa-arrow-right ml-1"></i>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
|
||||
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create your first job
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flow-root">
|
||||
<ul role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, job := range data.RecentJobs {
|
||||
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
|
||||
<div class="flex items-center space-x-4">
|
||||
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
if job.Status == "completed" {
|
||||
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
|
||||
<i class="fas fa-check text-green-600 dark:text-green-300"></i>
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else if job.Status == "failed" {
|
||||
<span class="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center dark:bg-red-900">
|
||||
<i class="fas fa-times text-red-600 dark:text-red-300"></i>
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else {
|
||||
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
|
||||
<i class="fas fa-sync-alt text-blue-600 dark:text-blue-300"></i>
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
|
||||
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
{ getConfigNameForHistory(job, data.Configs) }
|
||||
</p>
|
||||
<div class="flex items-center mt-1">
|
||||
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
|
||||
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
|
||||
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 text-sm leading-5 font-medium rounded-full text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
||||
<i class="fas fa-eye mr-1"></i>
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
|
||||
View
|
||||
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<a href="/jobs" class="w-full flex justify-center items-center px-4 py-2 border border-secondary-300 shadow-sm text-sm font-medium rounded-lg text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
||||
<i class="fas fa-list-ul mr-2"></i>
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list w-4 h-4 mr-2"></i>
|
||||
View all jobs
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-bolt mr-2 text-primary-500"></i>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Quick Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body space-y-4">
|
||||
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
|
||||
<i class="fas fa-plus-circle mr-2"></i>
|
||||
<div class="p-4 space-y-4">
|
||||
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-5 h-5 mr-2"></i>
|
||||
Create New Config
|
||||
</a>
|
||||
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
|
||||
<i class="fas fa-play-circle mr-2"></i>
|
||||
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
|
||||
Create New Job
|
||||
</a>
|
||||
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-history w-5 h-5 mr-2"></i>
|
||||
View Transfer History
|
||||
</a>
|
||||
|
||||
<!-- System Status Card -->
|
||||
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
|
||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Server</span>
|
||||
<span class="badge badge-success">Online</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Scheduler</span>
|
||||
<span class="badge badge-success">Running</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Database</span>
|
||||
<span class="badge badge-success">Connected</span>
|
||||
<!-- System Status Card with Flowbite design -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
|
||||
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Running
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
|
||||
if data.RcloneVersion != "" {
|
||||
{ data.RcloneVersion }
|
||||
} else {
|
||||
Unknown
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,6 +260,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@LayoutWithContext("Forgot Password", ctx) {
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="card overflow-hidden shadow-lg">
|
||||
<div class="p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Password Reset</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter your email to receive a reset link</p>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
|
||||
if errorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ errorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if successMessage != "" {
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ successMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/forgot-password"
|
||||
x-data="{
|
||||
email: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email && this.email.includes('@');
|
||||
}
|
||||
}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
x-model="email"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="you@example.com"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
We'll send a password reset link to this email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center py-3"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Send Reset Link
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Password Reset</h2>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Enter your email to receive a reset link</p>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</p>
|
||||
if errorMessage != "" {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if successMessage != "" {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ successMessage }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/forgot-password"
|
||||
x-data="{
|
||||
email: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email && this.email.includes('@');
|
||||
}
|
||||
}">
|
||||
<div>
|
||||
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
x-model="email"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="you@example.com"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
We'll send a password reset link to this email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Send Reset Link
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>Secure, encrypted connection</span>
|
||||
</div>
|
||||
@@ -114,125 +103,116 @@ templ ForgotPassword(ctx context.Context, errorMessage string, successMessage st
|
||||
// Reset password page for when users click the link from their email
|
||||
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
|
||||
@LayoutWithContext("Reset Password", ctx) {
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="card overflow-hidden shadow-lg">
|
||||
<div class="p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Reset Password</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Create a new password for your account</p>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
|
||||
if errorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ errorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/reset-password"
|
||||
x-data="{
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.password &&
|
||||
this.password.length >= 8 &&
|
||||
this.password === this.confirmPassword;
|
||||
}
|
||||
}">
|
||||
<input type="hidden" name="token" value={token}/>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">New Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Minimum 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm New Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
x-model="confirmPassword"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm"
|
||||
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-secondary-500 dark:text-secondary-400': !confirmPassword || password === confirmPassword}">
|
||||
<span x-show="!confirmPassword || password === confirmPassword">
|
||||
Passwords must match
|
||||
</span>
|
||||
<span x-show="confirmPassword && password !== confirmPassword">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Passwords do not match
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center py-3"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Reset Password
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Create a new password for your account</p>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</p>
|
||||
if errorMessage != "" {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/reset-password"
|
||||
x-data="{
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.password &&
|
||||
this.password.length >= 8 &&
|
||||
this.password === this.confirmPassword;
|
||||
}
|
||||
}">
|
||||
<input type="hidden" name="token" value={token}/>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">New Password</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Minimum 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-lock text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
x-model="confirmPassword"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm"
|
||||
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-gray-500 dark:text-gray-400': !confirmPassword || password === confirmPassword}">
|
||||
<span x-show="!confirmPassword || password === confirmPassword">
|
||||
Passwords must match
|
||||
</span>
|
||||
<span x-show="confirmPassword && password !== confirmPassword">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Passwords do not match
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Reset Password
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>Secure, encrypted connection</span>
|
||||
</div>
|
||||
|
||||
+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
|
||||
templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
if len(data.History) == 0 {
|
||||
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div class="flex justify-center mb-4">
|
||||
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<i class="fas fa-inbox text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No transfer history</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">No transfer history</h3>
|
||||
if data.SearchTerm != "" {
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No results found for "{ data.SearchTerm }". Try a different search term or <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">clear search</button>.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
No results found for "{ data.SearchTerm }". Try a different search term or
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
clear search
|
||||
</button>.
|
||||
</p>
|
||||
} else if data.CurrentPage > 1 {
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No more results on this page. <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">Return to first page</button>.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
No more results on this page.
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) }
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Return to first page
|
||||
</button>.
|
||||
</p>
|
||||
} else {
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Transfer history will appear here once jobs have run.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Transfer history will appear here once jobs have run.</p>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
|
||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, history := range data.History {
|
||||
<li class="hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
||||
if history.Status == "completed" {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
Completed
|
||||
</span>
|
||||
} else if history.Status == "failed" {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||
Failed
|
||||
</span>
|
||||
} else {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||
{ history.Status }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
|
||||
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 dark:border-secondary-600 text-sm leading-5 font-medium rounded-full text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i>
|
||||
class="text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 font-medium rounded-lg text-sm px-3 py-1.5 inline-flex items-center border border-gray-300 dark:border-gray-600 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 transition-colors">
|
||||
<i class="fas fa-eye mr-1.5"></i>
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-calendar-alt mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</p>
|
||||
if history.EndTime != nil {
|
||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-clock mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||
Duration: { history.EndTime.Sub(history.StartTime).String() }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
|
||||
if history.BytesTransferred > 0 {
|
||||
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<i class="fas fa-upload mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||
<p>
|
||||
{ formatBytes(history.BytesTransferred) } transferred
|
||||
</p>
|
||||
@@ -116,9 +138,9 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
</div>
|
||||
</div>
|
||||
if history.ErrorMessage != "" {
|
||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
|
||||
<div class="mt-2 p-3 rounded-lg border bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
<i class="fas fa-exclamation-triangle mr-1.5"></i>
|
||||
Error: { history.ErrorMessage }
|
||||
</p>
|
||||
</div>
|
||||
@@ -131,15 +153,17 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
|
||||
<!-- Pagination -->
|
||||
if data.TotalPages > 1 {
|
||||
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-secondary-50 dark:bg-secondary-800 rounded-lg">
|
||||
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full px-4 mb-4 sm:mb-0">
|
||||
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-white dark:bg-gray-800 rounded-lg shadow px-4">
|
||||
<!-- Mobile pagination -->
|
||||
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
|
||||
if data.CurrentPage > 1 {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#mobile-prev-indicator"
|
||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
</span>
|
||||
@@ -148,7 +172,7 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
</span>
|
||||
}
|
||||
@@ -159,7 +183,8 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#mobile-next-indicator"
|
||||
class="flex-1 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
</span>
|
||||
@@ -168,14 +193,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="flex-1 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between px-4">
|
||||
|
||||
<!-- Desktop pagination -->
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing
|
||||
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
|
||||
to
|
||||
@@ -185,23 +212,24 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<nav class="flex gap-2" aria-label="Pagination">
|
||||
<div>
|
||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
if data.CurrentPage > 1 {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#prev-indicator"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
<i class="fas fa-chevron-left text-xs"></i>
|
||||
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<span class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left text-xs"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -214,15 +242,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#next-indicator"
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
<i class="fas fa-chevron-right text-xs"></i>
|
||||
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="w-10 h-10 flex items-center justify-center bg-secondary-100 text-secondary-300 rounded dark:bg-secondary-800 dark:text-secondary-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<span class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-right text-xs"></i>
|
||||
</span>
|
||||
}
|
||||
</nav>
|
||||
@@ -235,42 +264,45 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
|
||||
templ History(ctx context.Context, data HistoryData) {
|
||||
@LayoutWithContext("Transfer History", ctx) {
|
||||
<div id="history-page" class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Transfer History
|
||||
</h1>
|
||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Pagination Controls -->
|
||||
<div class="mt-4 flex flex-col sm:flex-row justify-between items-center bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg mb-6">
|
||||
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="w-full sm:w-auto mb-4 sm:mb-0">
|
||||
<div class="flex">
|
||||
<div class="relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-secondary-400 dark:text-secondary-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={ data.SearchTerm }
|
||||
placeholder="Search by job name or status..."
|
||||
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
/>
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div class="w-full sm:w-auto relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={ data.SearchTerm }
|
||||
placeholder="Search by job name or status..."
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
/>
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-primary-500 dark:hover:bg-primary-600 transition-colors"
|
||||
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
>
|
||||
<span>Search</span>
|
||||
<span id="search-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-search mr-2 -ml-1"></i>
|
||||
Search
|
||||
<span id="search-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
@@ -280,46 +312,65 @@ templ History(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-indicator="#clear-indicator"
|
||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||
class="ml-2 inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
|
||||
>
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
<span id="clear-indicator" class="htmx-indicator ml-1">
|
||||
<span id="clear-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Show
|
||||
<select
|
||||
name="pageSize"
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-include="[name='search']"
|
||||
hx-indicator="#size-indicator"
|
||||
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
>
|
||||
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||
</select>
|
||||
entries
|
||||
<span id="size-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Show
|
||||
<select
|
||||
name="pageSize"
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-include="[name='search']"
|
||||
hx-indicator="#size-indicator"
|
||||
class="mx-1 rounded-md border-secondary-300 dark:border-secondary-600 py-1 text-base bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:border-primary-500 focus:outline-none focus:ring-primary-500 sm:text-sm"
|
||||
>
|
||||
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||
</select>
|
||||
entries
|
||||
<span id="size-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="history-content" class="mt-6">
|
||||
@HistoryContent(ctx, data)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('history-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
for i := 1; i <= totalPages; i++ {
|
||||
if i == currentPage {
|
||||
<span aria-current="page" class="w-10 h-10 flex items-center justify-center bg-primary-600 text-white rounded">
|
||||
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600">
|
||||
{ fmt.Sprint(i) }
|
||||
</span>
|
||||
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||
@@ -337,14 +388,17 @@ templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm stri
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||
class="w-10 h-10 flex items-center justify-center bg-secondary-200 text-secondary-800 hover:bg-secondary-300 rounded dark:bg-secondary-700 dark:text-secondary-200 dark:hover:bg-secondary-600 relative">
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
{ fmt.Sprint(i) }
|
||||
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else if i == currentPage-3 || i == currentPage+3 {
|
||||
<span class="w-10 h-10 flex items-center justify-center">...</span>
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||
...
|
||||
</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+124
-99
@@ -6,130 +6,155 @@ import (
|
||||
|
||||
templ Home(ctx context.Context) {
|
||||
@LayoutWithContext("Home", ctx) {
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center justify-center">
|
||||
<i class="fas fa-exchange-alt w-8 h-8 mr-3 text-blue-500"></i>
|
||||
Welcome to GoMFT
|
||||
</h1>
|
||||
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
|
||||
|
||||
if isLoggedIn(ctx) {
|
||||
<div class="space-y-8">
|
||||
<!-- Feature Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6">
|
||||
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Set up and manage file transfer configurations with ease.</p>
|
||||
<div class="flex justify-center">
|
||||
<a href="/configs" class="btn-primary inline-flex items-center">
|
||||
<span>View Configs</span>
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6">
|
||||
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Schedule Jobs</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Create and monitor automated file transfer jobs.</p>
|
||||
<div class="flex justify-center">
|
||||
<a href="/jobs" class="btn-primary inline-flex items-center">
|
||||
<span>View Jobs</span>
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6">
|
||||
<div class="rounded-full bg-green-100 dark:bg-green-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Track History</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">View detailed history of all file transfer operations.</p>
|
||||
<div class="flex justify-center">
|
||||
<a href="/history" class="btn-primary inline-flex items-center">
|
||||
<span>View History</span>
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-lg text-gray-500 dark:text-gray-400 mt-2">
|
||||
A modern managed file transfer solution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
if isLoggedIn(ctx) {
|
||||
<!-- Logged-in User View -->
|
||||
<div class="space-y-8">
|
||||
<!-- Feature Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Transfer Configs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 dark:bg-blue-900">
|
||||
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Transfer Configs</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-5">Set up and manage file transfer configurations with ease.</p>
|
||||
<a href="/configs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
View Configs
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access Section -->
|
||||
<div class="mt-12">
|
||||
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
|
||||
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
|
||||
Quick Access
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
|
||||
<!-- Schedule Jobs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 mb-4 dark:bg-indigo-900">
|
||||
<i class="fas fa-calendar-alt text-indigo-600 dark:text-indigo-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Schedule Jobs</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-5">Create and monitor automated file transfer jobs.</p>
|
||||
<a href="/jobs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
View Jobs
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
|
||||
</a>
|
||||
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
|
||||
</a>
|
||||
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track History Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-green-100 mb-4 dark:bg-green-900">
|
||||
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Track History</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-5">View detailed history of all file transfer operations.</p>
|
||||
<a href="/history" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
View History
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="inline-flex items-center justify-center w-64 h-64 mb-4">
|
||||
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
|
||||
|
||||
<!-- Quick Access Section -->
|
||||
<div class="mt-10">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-bolt text-blue-500 w-6 h-6 mr-2"></i>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Quick Access</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/dashboard" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-tachometer-alt text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Dashboard</span>
|
||||
</a>
|
||||
<a href="/configs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-plus-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">New Config</span>
|
||||
</a>
|
||||
<a href="/jobs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-play-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">New Job</span>
|
||||
</a>
|
||||
<a href="/profile" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-user-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Profile</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Login Card for Non-Logged-In Users -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex items-center justify-center w-32 h-32 mb-6">
|
||||
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Get Started</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6">Log in to access the file transfer management system.</p>
|
||||
<a href="/login" class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-lock mr-2"></i>
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
|
||||
<a href="/login" class="btn-primary inline-flex items-center px-8 py-3">
|
||||
<i class="fas fa-lock mr-2"></i>
|
||||
<span>Log In</span>
|
||||
</a>
|
||||
|
||||
<!-- Features Preview -->
|
||||
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
|
||||
<ul class="space-y-3 text-left">
|
||||
<div class="p-6 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white mb-4">Key Features</h3>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">Secure file transfers with encryption</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">Automated scheduling and monitoring</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">Comprehensive transfer history tracking</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">User-friendly interface with dark mode support</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">About GoMFT</h4>
|
||||
<p>GoMFT is a modern, secure file transfer solution that allows you to effortlessly move files between different systems and storage providers with robust tracking and monitoring.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+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
|
||||
// This is used for testing
|
||||
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
Job Run Details
|
||||
</h1>
|
||||
</div>
|
||||
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 inline-flex items-center">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||
Job Run Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Job Run Information Card -->
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">{ data.Job.Name }</h3>
|
||||
if data.JobHistory.Status == "completed" {
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
<i class="fas fa-check mr-1"></i> Completed
|
||||
</span>
|
||||
} else if data.JobHistory.Status == "failed" {
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||
<i class="fas fa-times mr-1"></i> Failed
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||
<i class="fas fa-spinner fa-spin mr-1"></i> Running
|
||||
</span>
|
||||
}
|
||||
<!-- Job Run Information Card -->
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{ data.Job.Name }</h3>
|
||||
if data.JobHistory.Status == "completed" {
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 inline-flex items-center">
|
||||
<i class="fas fa-check mr-2"></i> Completed
|
||||
</span>
|
||||
} else if data.JobHistory.Status == "failed" {
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300 inline-flex items-center">
|
||||
<i class="fas fa-times mr-2"></i> Failed
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 inline-flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i> Running
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Config: { data.Config.Name }</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-calendar-alt mr-2 text-gray-400 dark:text-gray-500"></i> Start Time
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</dd>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Config: { data.Config.Name }</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-alt mr-1"></i> Start Time
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-check mr-1"></i> End Time
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||
} else {
|
||||
<span class="italic text-secondary-500">In progress</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-clock mr-1"></i> Duration
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
||||
} else {
|
||||
<span class="italic text-secondary-500">In progress</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-upload mr-1"></i> Data Transferred
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-file mr-1"></i> Files Transferred
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ data.Job.Schedule }
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Configuration Details -->
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
Transfer Configuration
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.SourceType }</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">{ data.Config.DestinationType }</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.SourcePath }</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">{ data.Config.DestinationPath }</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">{ data.Config.FilePattern }</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Information (if any) -->
|
||||
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
Error Information
|
||||
</h3>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-calendar-check mr-2 text-gray-400 dark:text-gray-500"></i> End Time
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||
} else {
|
||||
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg">
|
||||
<pre class="text-sm text-red-800 dark:text-red-300 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-clock mr-2 text-gray-400 dark:text-gray-500"></i> Duration
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
||||
} else {
|
||||
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
||||
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
|
||||
<i class="fas fa-list-ul mr-2"></i> View All Jobs
|
||||
</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
|
||||
<i class="fas fa-edit mr-2"></i> Edit Job
|
||||
</a>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-upload mr-2 text-gray-400 dark:text-gray-500"></i> Data Transferred
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-file mr-2 text-gray-400 dark:text-gray-500"></i> Files Transferred
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-calendar-day mr-2 text-gray-400 dark:text-gray-500"></i> Job Schedule
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ data.Job.Schedule }
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Configuration Details -->
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cog mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||
Transfer Configuration
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Type</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.SourceType }</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Type</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.DestinationType }</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Path</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.SourcePath }</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Path</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.DestinationPath }</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">File Pattern</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white font-mono">{ data.Config.FilePattern }</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Information (if any) -->
|
||||
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
|
||||
<div class="p-4 mb-8 text-red-800 border-l-4 border-red-300 bg-red-50 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 text-red-600 dark:text-red-500"></i>
|
||||
<h3 class="text-lg font-medium">Error Information</h3>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<pre class="text-sm whitespace-pre-wrap font-mono p-3 bg-white dark:bg-gray-900 rounded-lg border border-red-200 dark:border-red-800">{ data.JobHistory.ErrorMessage }</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
||||
<a href="/jobs" class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700 inline-flex items-center justify-center">
|
||||
<i class="fas fa-list-ul mr-2"></i> View All Jobs
|
||||
</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800 inline-flex items-center justify-center">
|
||||
<i class="fas fa-edit mr-2"></i> Edit Job
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
+275
-189
@@ -6,55 +6,46 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// Dialog component for confirmation dialogs - copied from admin_tools.templ
|
||||
// Dialog component for confirmation dialogs using Flowbite modal
|
||||
templ JobDialog(id string, title string, message string, confirmClass string, confirmText string, action string, jobID uint, jobName string) {
|
||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<!-- Backdrop -->
|
||||
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||
<!-- Modal content -->
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
if action == "delete" {
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||
}
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
|
||||
hx-target="closest li"
|
||||
hx-swap="delete"
|
||||
data-job-name={ jobName }
|
||||
data-job-id={ fmt.Sprint(jobID) }
|
||||
id={ fmt.Sprintf("delete-btn-%d", jobID) }
|
||||
onclick={ triggerJobDelete(id, jobID, jobName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" onclick={ closeModal(id) } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ title }
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4 text-center">
|
||||
<p class="text-secondary-700 dark:text-secondary-300">
|
||||
{ message }
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick={ hideJobDialog(id) }>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-delete={ fmt.Sprintf("/jobs/%d", jobID) }
|
||||
hx-target="closest li"
|
||||
hx-swap="delete"
|
||||
data-job-name={ jobName }
|
||||
data-job-id={ fmt.Sprint(jobID) }
|
||||
id={ fmt.Sprintf("delete-btn-%d", jobID) }
|
||||
onclick={ triggerJobDelete(id, jobID, jobName) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideJobDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
}
|
||||
|
||||
script showJobDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
}
|
||||
|
||||
script triggerJobDelete(dialogId string, jobID uint, jobName string) {
|
||||
// Hide the dialog
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
document.getElementById(dialogId).classList.remove("flex");
|
||||
|
||||
// Add debugging info
|
||||
console.log(`Job deletion triggered for: ${jobName} (ID: ${jobID})`);
|
||||
@@ -75,38 +66,84 @@ type JobsData struct {
|
||||
}
|
||||
|
||||
templ Jobs(ctx context.Context, data JobsData) {
|
||||
@LayoutWithContext("Transfer Jobs", ctx) {
|
||||
@LayoutWithContext("Scheduled Jobs", ctx) {
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
<script>
|
||||
// Debug notification system
|
||||
console.log("Jobs template loaded, setting up notification system");
|
||||
|
||||
// Create a global notyf instance immediately
|
||||
window.notyf = new Notyf({
|
||||
duration: 3000,
|
||||
position: {
|
||||
x: 'right',
|
||||
y: 'top',
|
||||
},
|
||||
types: [
|
||||
{
|
||||
type: 'success',
|
||||
background: '#38c172',
|
||||
icon: {
|
||||
className: 'fas fa-check-circle',
|
||||
tagName: 'i'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'error',
|
||||
background: '#e3342f',
|
||||
icon: {
|
||||
className: 'fas fa-exclamation-circle',
|
||||
tagName: 'i'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
console.log("Notyf initialized:", window.notyf);
|
||||
// Function to create and show a toast
|
||||
function showToast(message, type) {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass, bgColorClass, textColorClass;
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||
textColorClass = 'text-green-500 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||
textColorClass = 'text-red-500 dark:text-red-200';
|
||||
} else {
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Set inner HTML with appropriate icon and message
|
||||
toast.innerHTML = `
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||
${type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>'}
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay to ensure the DOM has updated
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Global function to handle job running
|
||||
window.runJob = function(button) {
|
||||
@@ -126,22 +163,33 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
displayName = event.detail.headers['HX-Job-Name'];
|
||||
}
|
||||
console.log(`Showing success notification for job: ${displayName}`);
|
||||
window.notyf.success(`Job "${displayName}" started successfully!`);
|
||||
showToast(`Job "${displayName}" started successfully!`, 'success');
|
||||
} else {
|
||||
let errorMsg = `Failed to start job "${jobName}"`;
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||
}
|
||||
console.log(`Showing error notification: ${errorMsg}`);
|
||||
window.notyf.error(errorMsg);
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
// Handle modal hide buttons
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||
hideButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const modalId = this.getAttribute('data-modal-hide');
|
||||
const modal = document.getElementById(modalId);
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
console.log("HTMX before request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request by examining the URL and method
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
@@ -158,9 +206,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
});
|
||||
|
||||
// Track HTMX after-request events for job deletion
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
console.log("HTMX after request:", event.detail);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for job deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerJobDelete function
|
||||
@@ -191,7 +237,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
}
|
||||
|
||||
console.log(`Showing success notification for deleted job: ${jobName}`);
|
||||
window.notyf.success(`Job "${jobName}" deleted successfully`);
|
||||
showToast(`Job "${jobName}" deleted successfully`, 'success');
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingJob = false;
|
||||
@@ -234,7 +280,7 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
}
|
||||
|
||||
console.log(`Showing error notification: ${errorMsg}`);
|
||||
window.notyf.error(errorMsg);
|
||||
showToast(errorMsg, 'error');
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingJob = false;
|
||||
@@ -244,147 +290,187 @@ templ Jobs(ctx context.Context, data JobsData) {
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-exchange-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
Transfer Jobs
|
||||
<div id="jobs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="jobs-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-calendar-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<a href="/jobs/new" class="btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<a href="/jobs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
New Job
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
if len(data.Jobs) == 0 {
|
||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
|
||||
<i class="fas fa-list text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No jobs</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer job.</p>
|
||||
<h3 class="mt-2 text-lg font-medium text-gray-900 dark:text-white">No jobs</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by creating a new transfer job.</p>
|
||||
<div class="mt-6">
|
||||
<a href="/jobs/new" class="btn-primary">
|
||||
<a href="/jobs/new" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
New Job
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
for _, job := range data.Jobs {
|
||||
<li>
|
||||
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
||||
if job.Name != "" {
|
||||
{ job.Name }
|
||||
} else {
|
||||
{ job.Config.Name }
|
||||
}
|
||||
</p>
|
||||
if job.GetEnabled() {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
Active
|
||||
</span>
|
||||
} else {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||
Inactive
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<button
|
||||
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
|
||||
hx-swap="none"
|
||||
class="btn-primary btn-sm"
|
||||
data-job-id={ fmt.Sprint(job.ID) }
|
||||
data-job-name={ job.Name }
|
||||
onclick="window.runJob(this)">
|
||||
<i class="fas fa-play mr-1"></i>
|
||||
Run Now
|
||||
</button>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="btn-secondary btn-sm">
|
||||
<i class="fas fa-edit mr-1"></i>
|
||||
Edit
|
||||
</a>
|
||||
<!-- Add delete dialog for each job -->
|
||||
@JobDialog(
|
||||
fmt.Sprintf("delete-job-dialog-%d", job.ID),
|
||||
"Delete Job",
|
||||
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
job.ID,
|
||||
determineJobName(job),
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showJobDialog(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
||||
class="btn-danger btn-sm">
|
||||
<i class="fas fa-trash-alt mr-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-cogs flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
Configs:
|
||||
<span class="ml-1">
|
||||
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
|
||||
{ fmt.Sprintf("%d configurations", count) }
|
||||
} else if job.ConfigID > 0 {
|
||||
{ job.Config.Name }
|
||||
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg overflow-hidden">
|
||||
<div class="relative overflow-x-auto">
|
||||
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, job := range data.Jobs {
|
||||
<li>
|
||||
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
if job.Name != "" {
|
||||
{ job.Name }
|
||||
} else {
|
||||
{ "None" }
|
||||
{ job.Config.Name }
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
Schedule: { job.Schedule }
|
||||
</p>
|
||||
if job.LastRun != nil {
|
||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-history flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
|
||||
</p>
|
||||
if job.GetEnabled() {
|
||||
<span class="inline-flex items-center bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 ml-2">
|
||||
<i class="fas fa-circle-check text-green-500 dark:text-green-400 mr-1"></i>
|
||||
Active
|
||||
</span>
|
||||
} else {
|
||||
<span class="inline-flex items-center bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300 ml-2">
|
||||
<i class="fas fa-circle-xmark text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||
Inactive
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<button
|
||||
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
|
||||
hx-swap="none"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
data-job-id={ fmt.Sprint(job.ID) }
|
||||
data-job-name={ job.Name }
|
||||
onclick="window.runJob(this)">
|
||||
<i class="fas fa-play mr-1"></i>
|
||||
Run Now
|
||||
</button>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="py-1.5 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-pen-to-square mr-1"></i>
|
||||
Edit
|
||||
</a>
|
||||
<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>
|
||||
if job.NextRun != nil {
|
||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<p>
|
||||
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
||||
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
||||
</p>
|
||||
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('jobs-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('jobs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+418
-393
@@ -2,9 +2,22 @@ package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"github.com/gin-gonic/gin"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppVersion will be set at build time using ldflags
|
||||
// Example build command:
|
||||
// go build -ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=1.2.3"
|
||||
var AppVersion = "DEV"
|
||||
|
||||
// GetReleaseURL returns the URL to the specific GitHub release
|
||||
func GetReleaseURL() templ.SafeURL {
|
||||
return templ.SafeURL(fmt.Sprintf("https://github.com/starfleetcptn/gomft/releases/tag/%s", AppVersion))
|
||||
}
|
||||
|
||||
// CreateTemplateContext creates a new context with user information from Gin's context
|
||||
func CreateTemplateContext(c *gin.Context) context.Context {
|
||||
ctx := context.Background()
|
||||
@@ -29,453 +42,465 @@ templ Layout(title string) {
|
||||
|
||||
templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="light h-full p-0 m-0">
|
||||
<html lang="en" class="h-full bg-gray-50 dark:bg-gray-900">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||
<meta name="theme-color" content="#2563eb"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="manifest" href="/static/site.webmanifest">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/>
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
|
||||
<link rel="manifest" href="/static/site.webmanifest"/>
|
||||
<title>{ title } - GoMFT</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||
<!-- Notyf Toast Notifications -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/notyf@3/notyf.min.js"></script>
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="/static/dist/fontawesome/css/all.min.css"/>
|
||||
<!-- Application assets -->
|
||||
<link rel="stylesheet" href="/static/dist/app.css"/>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
||||
|
||||
<script>
|
||||
// Initialize Notyf and make it available globally
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.notyfInstance = new Notyf({
|
||||
duration: 3000,
|
||||
position: {
|
||||
x: 'right',
|
||||
y: 'bottom',
|
||||
},
|
||||
types: [
|
||||
{
|
||||
type: 'success',
|
||||
className: 'notyf__toast--success',
|
||||
background: '#10B981',
|
||||
icon: {
|
||||
className: 'fas fa-check-circle',
|
||||
tagName: 'i'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'error',
|
||||
className: 'notyf__toast--error',
|
||||
background: '#EF4444',
|
||||
icon: {
|
||||
className: 'fas fa-exclamation-circle',
|
||||
tagName: 'i'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
// Immediate theme application
|
||||
// This script runs before the DOM is fully loaded
|
||||
(function() {
|
||||
const isDark = localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'custom': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'custom-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
}
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.classList.add('dark');
|
||||
|
||||
// Apply dark theme to containers
|
||||
const containers = ['jobs-container', 'configs-container'];
|
||||
containers.forEach(function(id) {
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
container.classList.add('dark');
|
||||
container.style.backgroundColor = '#111827';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style type="text/tailwindcss">
|
||||
/* Reset default browser margins and padding */
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden; /* Prevent horizontal scrolling */
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 bg-secondary-200 text-secondary-800 rounded-lg hover:bg-secondary-300 transition-all duration-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-secondary-300 focus:ring-offset-2 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
||||
}
|
||||
.btn-warning {
|
||||
@apply px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2;
|
||||
}
|
||||
.form-input {
|
||||
@apply w-full px-3 py-2 border border-secondary-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 shadow-sm transition-all duration-200 dark:bg-secondary-800 dark:border-secondary-700 dark:text-white;
|
||||
}
|
||||
.form-checkbox {
|
||||
@apply rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:border-secondary-700 dark:bg-secondary-800;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-xl shadow-custom overflow-hidden border border-secondary-200 dark:border-secondary-700 transition-all duration-200 hover:shadow-custom-lg;
|
||||
}
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-900;
|
||||
}
|
||||
.card-body {
|
||||
@apply p-6;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
.badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100;
|
||||
}
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
.badge-info {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
.table-container {
|
||||
@apply overflow-hidden rounded-lg shadow-sm border border-secondary-200 dark:border-secondary-700;
|
||||
}
|
||||
.table {
|
||||
@apply min-w-full divide-y divide-secondary-200 dark:divide-secondary-700;
|
||||
}
|
||||
.table thead {
|
||||
@apply bg-secondary-50 dark:bg-secondary-900;
|
||||
}
|
||||
.table th {
|
||||
@apply px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider dark:text-secondary-400;
|
||||
}
|
||||
.table tbody {
|
||||
@apply bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700;
|
||||
}
|
||||
.table td {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-secondary-100;
|
||||
}
|
||||
.nav-link {
|
||||
@apply inline-flex items-center px-1 pt-1 text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 border-b-2 border-transparent hover:border-primary-500 transition-all duration-200;
|
||||
}
|
||||
.nav-link-active {
|
||||
@apply inline-flex items-center px-1 pt-1 text-primary-600 dark:text-primary-400 border-b-2 border-primary-500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
.dark body {
|
||||
@apply bg-secondary-900 text-secondary-100;
|
||||
}
|
||||
.dark .bg-white {
|
||||
@apply bg-secondary-800;
|
||||
}
|
||||
.dark .text-secondary-800 {
|
||||
@apply text-secondary-100;
|
||||
}
|
||||
.dark .text-secondary-700 {
|
||||
@apply text-secondary-200;
|
||||
}
|
||||
.dark .text-secondary-500 {
|
||||
@apply text-secondary-300;
|
||||
}
|
||||
.dark .hover\:text-secondary-700:hover {
|
||||
@apply text-secondary-100;
|
||||
}
|
||||
.dark .hover\:bg-secondary-100:hover {
|
||||
@apply bg-secondary-700;
|
||||
}
|
||||
.dark .bg-secondary-50 {
|
||||
@apply bg-secondary-900;
|
||||
}
|
||||
.dark .bg-secondary-200 {
|
||||
@apply bg-secondary-700;
|
||||
}
|
||||
.dark .shadow-sm {
|
||||
@apply shadow-secondary-900;
|
||||
}
|
||||
|
||||
/* Additional dark mode improvements */
|
||||
.dark .bg-secondary-50 {
|
||||
@apply bg-secondary-900;
|
||||
}
|
||||
.dark .bg-secondary-100 {
|
||||
@apply bg-secondary-800;
|
||||
}
|
||||
.dark .border-secondary-200 {
|
||||
@apply border-secondary-700;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
.pb-mobile-nav {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: theme('colors.secondary.100');
|
||||
border-radius: 4px;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.pb-mobile-nav {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: theme('colors.secondary.800');
|
||||
|
||||
/* Ensure dark mode background extends to full height */
|
||||
body.dark {
|
||||
background-color: #111827; /* gray-900 */
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme('colors.secondary.300');
|
||||
border-radius: 4px;
|
||||
|
||||
/* Force dark background on specific pages */
|
||||
body.dark .configs-page,
|
||||
body.dark .jobs-page {
|
||||
background-color: #111827 !important; /* gray-900 */
|
||||
min-height: 100vh;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: theme('colors.secondary.600');
|
||||
|
||||
/* Ensure dark mode fills entire page */
|
||||
body.dark {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme('colors.secondary.500');
|
||||
|
||||
/* Force container backgrounds */
|
||||
#jobs-container.dark, #configs-container.dark {
|
||||
background-color: #111827 !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: theme('colors.secondary.500');
|
||||
|
||||
/* Override any white backgrounds in dark mode */
|
||||
body.dark .bg-white {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full p-0 m-0">
|
||||
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;">
|
||||
if isLoggedIn(ctx) {
|
||||
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10 w-full">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<!-- Application Shell -->
|
||||
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
|
||||
<div class="flex flex-col flex-grow pt-5 bg-white dark:bg-gray-800 overflow-y-auto border-r border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center flex-shrink-0 px-4">
|
||||
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
GoMFT
|
||||
</a>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<a href="/dashboard" class="nav-link">
|
||||
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
|
||||
</div>
|
||||
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
|
||||
<div class="space-y-1">
|
||||
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/configs" class="nav-link">
|
||||
<i class="fas fa-cogs mr-1"></i> Configs
|
||||
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Configs
|
||||
</a>
|
||||
<a href="/jobs" class="nav-link">
|
||||
<i class="fas fa-tasks mr-1"></i> Jobs
|
||||
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Jobs
|
||||
</a>
|
||||
<a href="/history" class="nav-link">
|
||||
<i class="fas fa-history mr-1"></i> History
|
||||
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
History
|
||||
</a>
|
||||
<a href="/files" class="nav-link">
|
||||
<i class="fas fa-file-alt mr-1"></i> Files
|
||||
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Files
|
||||
</a>
|
||||
if isAdmin(ctx) {
|
||||
<a href="/admin/users" class="nav-link">
|
||||
<i class="fas fa-users mr-1"></i> Users
|
||||
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Administration
|
||||
</p>
|
||||
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Users
|
||||
</a>
|
||||
<a href="/admin/tools" class="nav-link">
|
||||
<i class="fas fa-tools mr-1"></i> Admin Tools
|
||||
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Roles
|
||||
</a>
|
||||
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Audit Logs
|
||||
</a>
|
||||
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Database Tools
|
||||
</a>
|
||||
// Settings Dropdown
|
||||
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
|
||||
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||
// <li>
|
||||
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||
// </li>
|
||||
<li>
|
||||
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Authentication Providers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Notification Services
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<i class="fas fa-sun hidden dark:block"></i>
|
||||
<i class="fas fa-moon block dark:hidden"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sm:hidden flex items-center">
|
||||
<button
|
||||
id="mobile-menu-button"
|
||||
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
|
||||
x-data="{}"
|
||||
@click="$dispatch('toggle-mobile-menu')"
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Mobile menu -->
|
||||
<div class="md:hidden fixed top-0 z-30 w-full">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<a href="/" class="flex items-center text-primary-600 dark:text-primary-400">
|
||||
<i class="fas fa-exchange-alt text-xl"></i>
|
||||
</a>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button data-drawer-target="mobile-menu" data-drawer-toggle="mobile-menu" class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bars text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div x-data="{ open: false }" class="relative">
|
||||
<button @click="open = !open" class="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
|
||||
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
|
||||
</div>
|
||||
</button>
|
||||
<div x-show="open"
|
||||
@click.away="open = false"
|
||||
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-secondary-800 dark:ring-secondary-700 animate-fadeIn"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
tabindex="-1">
|
||||
<div class="px-4 py-2 text-xs text-secondary-500 dark:text-secondary-400 border-b border-secondary-200 dark:border-secondary-700">
|
||||
Signed in as <span class="font-medium">{ getUserEmail(ctx) }</span>
|
||||
</div>
|
||||
<a href="/profile" class="block px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-100 dark:text-secondary-200 dark:hover:bg-secondary-700" role="menuitem">
|
||||
<i class="fas fa-user-circle mr-2"></i> Profile
|
||||
</a>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700" role="menuitem">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<div
|
||||
x-data="{ open: false }"
|
||||
x-on:toggle-mobile-menu.window="open = !open"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md 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
|
||||
<!-- Add this drawer element for mobile menu -->
|
||||
<div id="mobile-menu" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-64 dark:bg-gray-800" tabindex="-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
GoMFT
|
||||
</a>
|
||||
<a href="/admin/tools" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tools mr-2"></i> Admin Tools
|
||||
</a>
|
||||
}
|
||||
<a href="/profile" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-user-circle mr-2"></i> Profile
|
||||
</a>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="w-full text-left block px-3 py-2 rounded-md text-base font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900 hover:text-red-700 dark:hover:text-red-400">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
|
||||
<button type="button" data-drawer-hide="mobile-menu" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Copy the same navigation items from the desktop sidebar -->
|
||||
<nav class="mt-5">
|
||||
<div class="space-y-1">
|
||||
<!-- Copy the same links from the desktop sidebar -->
|
||||
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Configs
|
||||
</a>
|
||||
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Jobs
|
||||
</a>
|
||||
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
History
|
||||
</a>
|
||||
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Files
|
||||
</a>
|
||||
if isAdmin(ctx) {
|
||||
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Administration
|
||||
</p>
|
||||
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Users
|
||||
</a>
|
||||
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Roles
|
||||
</a>
|
||||
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Audit Logs
|
||||
</a>
|
||||
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Database Tools
|
||||
</a>
|
||||
// Settings Dropdown
|
||||
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
|
||||
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||
// <li>
|
||||
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||
// </li>
|
||||
<li>
|
||||
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Authentication Providers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Notification Services
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Main Content -->
|
||||
<div class="md:pl-64 flex flex-col flex-1 w-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Main header -->
|
||||
<header class="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div class="flex items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1>
|
||||
</div>
|
||||
<!-- Theme toggle and user menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<i class="fas fa-sun hidden dark:block"></i>
|
||||
<i class="fas fa-moon block dark:hidden"></i>
|
||||
</button>
|
||||
<!-- Notification Bell -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600 relative"
|
||||
id="notification-bell"
|
||||
data-dropdown-toggle="notification-dropdown"
|
||||
hx-get="/notifications/dropdown"
|
||||
hx-trigger="click once"
|
||||
hx-target="#notification-dropdown-content"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<span class="sr-only">View notifications</span>
|
||||
<i class="fas fa-bell"></i>
|
||||
<!-- Notification badge will be loaded dynamically -->
|
||||
<div hx-get="/notifications/count" hx-trigger="load, notification-updated from:body" id="notification-count-container"></div>
|
||||
</button>
|
||||
<!-- Notification dropdown -->
|
||||
<div
|
||||
class="hidden overflow-hidden z-50 my-4 max-w-sm md:max-w-md w-full text-base list-none bg-white rounded divide-y divide-gray-100 shadow-lg dark:divide-gray-600 dark:bg-gray-700 rounded-xl"
|
||||
id="notification-dropdown"
|
||||
style="min-width: 320px; width: 100%;"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<div id="notification-dropdown-content">
|
||||
<!-- Content will be loaded dynamically via HTMX -->
|
||||
<div class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
|
||||
Notifications
|
||||
</div>
|
||||
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-pulse flex flex-col items-center">
|
||||
<div class="rounded-full bg-gray-200 dark:bg-gray-700 h-12 w-12 mb-2"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/notifications" class="block py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:underline">
|
||||
<div class="inline-flex items-center">
|
||||
<svg aria-hidden="true" class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
View all
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User menu dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex text-sm rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||
id="user-menu-button"
|
||||
data-dropdown-toggle="user-dropdown"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
|
||||
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600"
|
||||
id="user-dropdown"
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
<span class="block text-sm text-gray-900 dark:text-white">{ getUserEmail(ctx) }</span>
|
||||
</div>
|
||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||
<li>
|
||||
<a
|
||||
href="/profile"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
|
||||
>
|
||||
<i class="fas fa-user-circle mr-2 w-4"></i>
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-left flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt mr-2 w-4"></i>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="py-6 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Notification area -->
|
||||
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
||||
{ children... }
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>GoMFT © { getCurrentYear() }</span>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tag"></i> { AppVersion }
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<main class="flex-grow w-full max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 animate-fadeIn pb-mobile-nav">
|
||||
<!-- Hidden notification area for HTMX targets -->
|
||||
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
||||
|
||||
{ children... }
|
||||
</main>
|
||||
<footer class="bg-white dark:bg-secondary-800 shadow-inner mt-auto w-full">
|
||||
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||
<p class="text-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
GoMFT © { getCurrentYear() } | Secure File Transfer Solution
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
if isLoggedIn(ctx) {
|
||||
<!-- Mobile Bottom Navigation -->
|
||||
<div class="sm:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 shadow-lg w-full p-0 m-0">
|
||||
<div class="grid grid-cols-6 h-16">
|
||||
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tachometer-alt text-lg"></i>
|
||||
<span class="text-xs mt-1">Dashboard</span>
|
||||
</a>
|
||||
<a href="/configs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-cogs text-lg"></i>
|
||||
<span class="text-xs mt-1">Configs</span>
|
||||
</a>
|
||||
<a href="/jobs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tasks text-lg"></i>
|
||||
<span class="text-xs mt-1">Jobs</span>
|
||||
</a>
|
||||
<a href="/history" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-history text-lg"></i>
|
||||
<span class="text-xs mt-1">History</span>
|
||||
</a>
|
||||
<a href="/files" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-file-alt text-lg"></i>
|
||||
<span class="text-xs mt-1">Files</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
x-data="{}"
|
||||
@click="$dispatch('toggle-mobile-menu')"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
||||
<span class="text-xs mt-1">More</span>
|
||||
</button>
|
||||
</div>
|
||||
} 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>
|
||||
}
|
||||
<!-- Scripts -->
|
||||
<!-- Alpine.js and dependencies -->
|
||||
<script defer src="/static/dist/vendor.js"></script>
|
||||
<!-- Application scripts -->
|
||||
<script defer src="/static/dist/app.js"></script>
|
||||
<script defer src="/static/dist/init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -512,17 +537,17 @@ func isLoggedIn(ctx context.Context) bool {
|
||||
func getUserInitial(ctx context.Context) string {
|
||||
// Try as string first
|
||||
if username, ok := ctx.Value("username").(string); ok && username != "" {
|
||||
return string(username[0])
|
||||
return strings.ToUpper(string(username[0]))
|
||||
}
|
||||
// Try as interface{} (from JWT claims)
|
||||
if username, ok := ctx.Value("username").(interface{}); ok {
|
||||
if strVal, ok := username.(string); ok && strVal != "" {
|
||||
return string(strVal[0])
|
||||
return strings.ToUpper(string(strVal[0]))
|
||||
}
|
||||
}
|
||||
// Try email as fallback
|
||||
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||
return string(email[0])
|
||||
return strings.ToUpper(string(email[0]))
|
||||
}
|
||||
return "U"
|
||||
}
|
||||
@@ -531,12 +556,12 @@ func getUserInitial(ctx context.Context) string {
|
||||
func getUserEmail(ctx context.Context) string {
|
||||
// Try as string first
|
||||
if email, ok := ctx.Value("email").(string); ok && email != "" {
|
||||
return email
|
||||
return strings.ToLower(email)
|
||||
}
|
||||
// Try as interface{} (from JWT claims)
|
||||
if email, ok := ctx.Value("email").(interface{}); ok {
|
||||
if strVal, ok := email.(string); ok && strVal != "" {
|
||||
return strVal
|
||||
return strings.ToLower(strVal)
|
||||
}
|
||||
}
|
||||
return "user@example.com"
|
||||
@@ -544,5 +569,5 @@ func getUserEmail(ctx context.Context) string {
|
||||
|
||||
// Helper function to get current year
|
||||
func getCurrentYear() string {
|
||||
return "2025"
|
||||
}
|
||||
return time.Now().Format("2006")
|
||||
}
|
||||
|
||||
+114
-116
@@ -7,131 +7,129 @@ import (
|
||||
|
||||
templ Login(ctx context.Context, errorMessage string) {
|
||||
@LayoutWithContext("Login", ctx) {
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="card overflow-hidden shadow-lg">
|
||||
<div class="p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Sign In</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Access your GoMFT account</p>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||
<i class="fas fa-lock text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
|
||||
if errorMessage != "" {
|
||||
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ errorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ errorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/login"
|
||||
x-data="{
|
||||
email: '',
|
||||
password: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email && this.password;
|
||||
}
|
||||
}">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
x-model="email"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="you@example.com"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
||||
<label for="remember-me" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">Remember me</label>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<a href="/forgot-password" class="font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">Forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center py-3"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i>
|
||||
Sign in
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Sign In</h2>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Access your GoMFT account</p>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
if errorMessage != "" {
|
||||
if strings.HasPrefix(errorMessage, "Password reset") || strings.Contains(errorMessage, "success") {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/login"
|
||||
x-data="{
|
||||
email: '',
|
||||
password: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email && this.password;
|
||||
}
|
||||
}">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
x-model="email"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="you@example.com"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary-600 focus:ring-2 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800"/>
|
||||
<label for="remember-me" class="ms-2 text-sm text-gray-900 dark:text-gray-300">Remember me</label>
|
||||
</div>
|
||||
<a href="/forgot-password" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i>
|
||||
Sign in
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Contact an administrator to create an account
|
||||
</p>
|
||||
|
||||
<!-- External Authentication Providers -->
|
||||
<div id="external-auth-providers" class="mt-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
|
||||
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
|
||||
<div class="animate-pulse flex justify-center">
|
||||
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>Secure, encrypted connection</span>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// Dialog component for 2FA disable confirmation
|
||||
// Dialog component for 2FA disable confirmation with Flowbite styling
|
||||
templ TwoFactorDisableDialog() {
|
||||
<div id="disable-2fa-dialog" class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-shield-alt text-yellow-400 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
Disable Two-Factor Authentication
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<p class="text-secondary-700 dark:text-secondary-300 mb-4">
|
||||
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="current-password-2fa" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<div id="disable-2fa-dialog" tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full inset-0 overflow-y-auto overflow-x-hidden bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow-xl dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-5 text-center">
|
||||
<div class="h-14 w-14 rounded-full bg-yellow-100 dark:bg-yellow-900 p-2 flex items-center justify-center mx-auto mb-5">
|
||||
<i class="fas fa-shield-alt text-yellow-500 dark:text-yellow-300 text-xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-5 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Disable Two-Factor Authentication
|
||||
</h3>
|
||||
<p class="mb-6 text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
||||
</p>
|
||||
<div class="mb-6">
|
||||
<label for="current-password-2fa" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-lock mr-1"></i> Current Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="current-password-2fa"
|
||||
name="current_password"
|
||||
class="form-input pl-10 w-full"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter your current password"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="disable-2fa-result"></div>
|
||||
<div id="disable-2fa-result" class="mb-5"></div>
|
||||
<div class="flex justify-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
onclick="submitDisable2FA()">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
<button type="button" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" onclick="hideDisable2FADialog()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick="hideDisable2FADialog()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
onclick="submitDisable2FA()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -68,6 +66,7 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
// Global functions for dialog control
|
||||
window.hideDisable2FADialog = function() {
|
||||
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
||||
document.getElementById('disable-2fa-dialog').classList.remove('flex');
|
||||
document.getElementById('current-password-2fa').value = '';
|
||||
document.getElementById('disable-2fa-result').innerHTML = '';
|
||||
};
|
||||
@@ -75,14 +74,18 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
window.showDisable2FADialog = function() {
|
||||
console.log('Showing 2FA disable dialog');
|
||||
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
||||
document.getElementById('disable-2fa-dialog').classList.add('flex');
|
||||
};
|
||||
|
||||
window.submitDisable2FA = function() {
|
||||
const password = document.getElementById('current-password-2fa').value;
|
||||
if (!password) {
|
||||
document.getElementById('disable-2fa-result').innerHTML = `
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded" role="alert">
|
||||
<span class="block sm:inline">Current password is required</span>
|
||||
<div class="p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span>Current password is required</span>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
@@ -111,64 +114,88 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</script>
|
||||
|
||||
@TwoFactorDisableDialog()
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-user-circle mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Profile Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-circle w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Profile
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Profile Information Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-id-card mr-2 text-primary-500"></i>
|
||||
Profile Information
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
|
||||
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
|
||||
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-id-card text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Profile Information
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Personal details and application settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-5">
|
||||
<dl class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">{ user.Email }</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Role</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
if user.GetIsAdmin() {
|
||||
<span class="badge badge-success">
|
||||
<i class="fas fa-user-shield mr-1"></i> Administrator
|
||||
</span>
|
||||
} else {
|
||||
<span class="badge badge-info">
|
||||
<i class="fas fa-user mr-1"></i> Regular User
|
||||
</span>
|
||||
}
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Roles</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if user.GetIsAdmin() {
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-blue-900 dark:text-blue-300 inline-flex items-center">
|
||||
<i class="fas fa-user-shield w-3 h-3 mr-1.5"></i> Administrator
|
||||
</span>
|
||||
}
|
||||
for _, role := range user.Roles {
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-gray-700 dark:text-gray-300 inline-flex items-center">
|
||||
<i class="fas fa-user-tag w-3 h-3 mr-1.5"></i> { role.Name }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Permissions</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
for _, role := range user.Roles {
|
||||
for _, perm := range role.GetPermissions() {
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
|
||||
{ perm }
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
if user.TwoFactorEnabled {
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-success">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Enabled
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-green-900 dark:text-green-300 inline-flex items-center">
|
||||
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Enabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/profile/2fa/backup-codes" class="btn-secondary btn-sm">
|
||||
<i class="fas fa-key mr-1"></i>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<a href="/profile/2fa/backup-codes" class="py-2 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center shadow-sm">
|
||||
<i class="fas fa-key w-3 h-3 mr-1.5"></i>
|
||||
Manage Backup Codes
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger btn-sm"
|
||||
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800 shadow-sm"
|
||||
onclick="showDisable2FADialog()">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
<i class="fas fa-times w-3 h-3 mr-1.5"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
@@ -176,13 +203,13 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
} else {
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="badge badge-warning">
|
||||
<i class="fas fa-shield-alt mr-1"></i> Disabled
|
||||
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-yellow-900 dark:text-yellow-300 inline-flex items-center">
|
||||
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Disabled
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/profile/2fa/setup" class="btn-primary btn-sm">
|
||||
<i class="fas fa-lock mr-1"></i>
|
||||
<div class="mt-2">
|
||||
<a href="/profile/2fa/setup" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm">
|
||||
<i class="fas fa-lock w-3 h-3 mr-1.5"></i>
|
||||
Enable 2FA
|
||||
</a>
|
||||
</div>
|
||||
@@ -191,12 +218,12 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
<form
|
||||
hx-post="/profile/theme"
|
||||
hx-swap="none"
|
||||
class="flex items-center space-x-4">
|
||||
class="flex items-center space-x-5">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -206,9 +233,9 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
checked?={ user.Theme == "light" || user.Theme == "" }
|
||||
hx-trigger="change"
|
||||
hx-post="/profile/theme"
|
||||
class="form-checkbox" />
|
||||
<label for="theme-light" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<i class="fas fa-sun mr-1"></i> Light
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<label for="theme-light" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
<i class="fas fa-sun mr-1.5"></i> Light
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -220,9 +247,9 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
checked?={ user.Theme == "dark" }
|
||||
hx-trigger="change"
|
||||
hx-post="/profile/theme"
|
||||
class="form-checkbox" />
|
||||
<label for="theme-dark" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<i class="fas fa-moon mr-1"></i> Dark
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<label for="theme-dark" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
<i class="fas fa-moon mr-1.5"></i> Dark
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -234,9 +261,9 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
checked?={ user.Theme == "system" }
|
||||
hx-trigger="change"
|
||||
hx-post="/profile/theme"
|
||||
class="form-checkbox" />
|
||||
<label for="theme-system" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<i class="fas fa-desktop mr-1"></i> System
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<label for="theme-system" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
<i class="fas fa-desktop mr-1.5"></i> System
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
@@ -247,15 +274,21 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</div>
|
||||
|
||||
<!-- Change Password Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-key mr-2 text-primary-500"></i>
|
||||
Change Password
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
|
||||
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
|
||||
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Change Password
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your password to keep your account secure</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-5">
|
||||
<form
|
||||
method="POST"
|
||||
action="/change-password"
|
||||
@@ -264,7 +297,7 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"X-Profile-Page": "true"}'
|
||||
hx-indicator="#password-change-indicator"
|
||||
class="space-y-4"
|
||||
class="space-y-5"
|
||||
x-data="{
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
@@ -283,57 +316,57 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
<div id="password-result"></div>
|
||||
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-lock mr-1"></i> Current Password
|
||||
<label for="current-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-lock mr-1.5"></i> Current Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="current-password"
|
||||
name="current_password"
|
||||
x-model="currentPassword"
|
||||
class="form-input pl-10 w-full"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-lock-open mr-1"></i> New Password
|
||||
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-lock-open mr-1.5"></i> New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="new-password"
|
||||
name="new_password"
|
||||
x-model="newPassword"
|
||||
class="form-input pl-10 w-full"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-check-double mr-1"></i> Confirm New Password
|
||||
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-check-double mr-1.5"></i> Confirm New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
name="confirm_password"
|
||||
x-model="confirmPassword"
|
||||
class="form-input pl-10 w-full"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required/>
|
||||
</div>
|
||||
@@ -342,13 +375,13 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center py-3"
|
||||
class="w-full text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Update Password
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -359,9 +392,11 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<div class="p-5 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-xl">
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="mr-3 text-blue-500 flex-shrink-0">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -369,10 +404,15 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>All profile changes are securely logged for your protection</span>
|
||||
<div class="mt-8">
|
||||
<div class="p-5 text-sm text-blue-800 rounded-xl bg-blue-50 border border-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:border-blue-800 flex items-start shadow-sm">
|
||||
<div class="mr-3 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>All profile changes are securely logged for your protection. For security reasons, you'll be required to enter your password for sensitive changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,63 +1,66 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
templ NameField() {
|
||||
<div class="space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" name="name" id="name" x-model="name" required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Transfer configuration name" />
|
||||
<div class="mb-6">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-tag text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="name" id="name" x-model="name" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Transfer configuration name" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ FilePatternFields() {
|
||||
<div class="space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="file_pattern" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">File
|
||||
Pattern</label>
|
||||
<div class="mb-6">
|
||||
<label for="file_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Pattern</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-filter text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-filter text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="*.txt, *.csv" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Glob pattern for files to transfer. Leave empty to transfer all files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="output_pattern"
|
||||
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Output Pattern</label>
|
||||
<div class="mb-6">
|
||||
<label for="output_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Output Pattern</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-file-export text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-file-export text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="${filename}" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
||||
</p>
|
||||
<div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Pattern for filenames. Available variables:<br />
|
||||
${`filename`} - Original filename without extension (e.g., "report")<br />
|
||||
${`ext`} - Original file extension (e.g., "csv")<br />
|
||||
${`date:format`} - Current date using Go's time format:<br />
|
||||
• 2006-01-02 → YYYY-MM-DD<br />
|
||||
• 20060102 → YYYYMMDD<br />
|
||||
• 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS<br />
|
||||
Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"
|
||||
|
||||
<div class="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Available variables:</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<span class="block">• ${`filename`} - Original filename without extension (e.g., "report")</span>
|
||||
<span class="block">• ${`ext`} - Original file extension (e.g., "csv")</span>
|
||||
<span class="block">• ${`date:format`} - Current date using Go's time format:</span>
|
||||
<span class="block pl-4">- 2006-01-02 → YYYY-MM-DD</span>
|
||||
<span class="block pl-4">- 20060102 → YYYYMMDD</span>
|
||||
<span class="block pl-4">- 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS</span>
|
||||
<span class="block italic mt-2">Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,127 +68,129 @@ templ FilePatternFields() {
|
||||
}
|
||||
|
||||
templ ArchiveOptions() {
|
||||
<div class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-6 bg-secondary-50 dark:bg-secondary-800/50 shadow-sm">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Archive & Delete Options</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="archive_enabled" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="archive_enabled" name="archive_enabled" type="checkbox" x-model="archiveEnabled"
|
||||
class="sr-only"
|
||||
:value="archiveEnabled ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="archiveEnabled ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Enable archiving
|
||||
</div>
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="archive_enabled" name="archive_enabled" x-model="archiveEnabled"
|
||||
class="sr-only peer" :value="archiveEnabled ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Enable archiving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-4" x-show="archiveEnabled">
|
||||
<label for="archive_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Archive Path</label>
|
||||
<div x-show="archiveEnabled" class="mt-4">
|
||||
<label for="archive_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Archive Path</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</span>
|
||||
<input id="archive_path" name="archive_path" type="text"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Path to archive files"
|
||||
x-model="archivePath"
|
||||
/>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input id="archive_path" name="archive_path" type="text" x-model="archivePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Path to archive files" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Files will be moved here after successful transfer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="delete_after_transfer" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="delete_after_transfer" name="delete_after_transfer" type="checkbox" x-model="deleteAfterTransfer"
|
||||
class="sr-only"
|
||||
:value="deleteAfterTransfer ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="deleteAfterTransfer ? 'transform translate-x-6 bg-red-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Delete source files after transfer
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="delete_after_transfer" name="delete_after_transfer" x-model="deleteAfterTransfer"
|
||||
class="sr-only peer" :value="deleteAfterTransfer ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-red-300 dark:peer-focus:ring-red-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-red-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Delete source files after transfer</span>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-red-500 dark:text-red-400" x-show="deleteAfterTransfer">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i> Warning: This will permanently delete the original files
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="deleteAfterTransfer" class="mt-2">
|
||||
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/30 dark:text-red-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span>Warning: This will permanently delete the original files</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="skip_processed_files" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="skip_processed_files" name="skip_processed_files" type="checkbox" x-model="skipProcessedFiles"
|
||||
class="sr-only"
|
||||
:value="skipProcessedFiles ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="skipProcessedFiles ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Skip files that have already been processed
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="skip_processed_files" name="skip_processed_files" x-model="skipProcessedFiles"
|
||||
class="sr-only peer" :value="skipProcessedFiles ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Skip files that have already been processed</span>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Files with the same hash that have been successfully processed before will be skipped
|
||||
</p>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Files with the same hash that have been successfully processed before will be skipped
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="max_concurrent_transfers" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Concurrent Transfers</label>
|
||||
<div class="flex items-center">
|
||||
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
||||
class="form-range w-2/3 h-2 bg-secondary-200 dark:bg-secondary-700 rounded-lg appearance-none cursor-pointer"
|
||||
min="1" max="32" step="1"
|
||||
x-model="maxConcurrentTransfers"
|
||||
/>
|
||||
<span class="ml-4 text-secondary-700 dark:text-secondary-300" x-text="maxConcurrentTransfers"></span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<div class="mt-6">
|
||||
<label for="max_concurrent_transfers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Concurrent Transfers: <span x-text="maxConcurrentTransfers"></span>
|
||||
</label>
|
||||
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
min="1" max="32" step="1" x-model="maxConcurrentTransfers">
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ RcloneFlags() {
|
||||
<div>
|
||||
<label for="rclone_flags" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300">Rclone Flags</label>
|
||||
<div class="mt-1 relative rounded-md shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-flag text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="mb-6">
|
||||
<label for="command_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Rclone Command</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-terminal text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
@RcloneCommandOptions()
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the rclone command to use for this configuration.
|
||||
</p>
|
||||
|
||||
<!-- Command flags container - will be populated via HTMX -->
|
||||
<div id="command-flags-container" class="mt-4"></div>
|
||||
|
||||
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-flag text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
|
||||
class="form-input pl-10 w-full rounded-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" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Optional: Additional rclone flags for fine-tuning the transfer.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
// New placeholder templ for rclone command options
|
||||
templ RcloneCommandOptions() {
|
||||
<div
|
||||
hx-get="/api/rclone/commands"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML">
|
||||
<!-- Loading placeholder -->
|
||||
<option value="">Loading commands...</option>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ SourceSelection() {
|
||||
<div class="sm:col-span-3">
|
||||
<label for="source_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Source
|
||||
Type</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<select id="source_type" name="source_type" x-model="sourceType"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="local">Local</option>
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="ftp">FTP</option>
|
||||
@@ -194,23 +199,22 @@ templ SourceSelection() {
|
||||
<option value="smb">SMB</option>
|
||||
<option value="nextcloud">NextCloud</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
<option value="gdrive">Google Drive</option>
|
||||
<option value="gphotos">Google Photos</option>
|
||||
<option value="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ DestinationSelection() {
|
||||
<div class="sm:col-span-3">
|
||||
<label for="destination_type"
|
||||
class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Destination Type</label>
|
||||
<div class="mb-6">
|
||||
<label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<select id="destination_type" name="destination_type" x-model="destinationType"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="local">Local</option>
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="ftp">FTP</option>
|
||||
@@ -219,9 +223,241 @@ templ DestinationSelection() {
|
||||
<option value="smb">SMB</option>
|
||||
<option value="nextcloud">NextCloud</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
<option value="gdrive">Google Drive</option>
|
||||
<option value="gphotos">Google Photos</option>
|
||||
<option value="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// RcloneCommandOptionsContent renders the command options organized by category
|
||||
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string) {
|
||||
<select id="command_id" name="command_id" x-model="commandId"
|
||||
hx-get="/api/rclone/command-flags"
|
||||
hx-target="#command-flags-container"
|
||||
hx-trigger="change"
|
||||
hx-include="[name='command_id']"
|
||||
@change="updateCommandRequirements()"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">Select command...</option>
|
||||
for _, category := range categories {
|
||||
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
|
||||
<optgroup label={ category }>
|
||||
for _, cmd := range commands {
|
||||
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
}
|
||||
|
||||
// RcloneCommandFlagsContent renders the command flags for a selected command
|
||||
templ RcloneCommandFlagsContent(command *db.RcloneCommand) {
|
||||
if command == nil {
|
||||
<div class="p-4 text-red-500">Command not found</div>
|
||||
return
|
||||
}
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-2">{ command.Name }</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{ command.Description }</p>
|
||||
|
||||
if len(command.Flags) > 0 {
|
||||
<div class="mb-3">
|
||||
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Command Flags:</h5>
|
||||
<div class="space-y-3">
|
||||
for _, flag := range command.Flags {
|
||||
if flag.DataType == "bool" {
|
||||
<!-- Boolean flag (simple checkbox) -->
|
||||
<div class="flex items-start mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={ fmt.Sprintf("flag_%d", flag.ID) }
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||
{ flag.Name } - { flag.Description }
|
||||
</label>
|
||||
if flag.DefaultValue != "" {
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Default: { flag.DefaultValue }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Non-boolean flag (requires value) -->
|
||||
<div class="flex items-start mb-4 w-full">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
onclick="toggleFlagValue(this)"
|
||||
/>
|
||||
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||
{ flag.Name } - { flag.Description }
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-2">
|
||||
@renderFlagInput(flag)
|
||||
if flag.DefaultValue != "" {
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript to handle enabling/disabling flag value inputs -->
|
||||
<script>
|
||||
function toggleFlagValue(checkbox) {
|
||||
const inputId = checkbox.getAttribute('data-input-id');
|
||||
const input = document.getElementById(inputId);
|
||||
|
||||
if (input) {
|
||||
input.disabled = !checkbox.checked;
|
||||
if (checkbox.checked) {
|
||||
// Focus on the input when enabled
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// Also enable/disable the hidden input for flag ID
|
||||
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
|
||||
const hiddenInput = document.getElementById(hiddenId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.disabled = !checkbox.checked;
|
||||
console.log('Toggle hidden input:', hiddenId, checkbox.checked);
|
||||
}
|
||||
|
||||
// For debugging
|
||||
console.log('Toggle flag value:', inputId, checkbox.checked);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all flag inputs on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const checkboxes = document.querySelectorAll('input[id^="flag_enable_"]');
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
const inputId = checkbox.getAttribute('data-input-id');
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) {
|
||||
input.disabled = !checkbox.checked;
|
||||
|
||||
// Also initialize the hidden input
|
||||
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
|
||||
const hiddenInput = document.getElementById(hiddenId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.disabled = !checkbox.checked;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
|
||||
<div class="flex items-center mb-1">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="font-medium">Usage Examples</span>
|
||||
</div>
|
||||
if command.Name == "ls" ||
|
||||
command.Name == "lsd" ||
|
||||
command.Name == "lsl" ||
|
||||
command.Name == "lsjson" ||
|
||||
command.Name == "lsf" ||
|
||||
command.Name == "delete" ||
|
||||
command.Name == "purge" ||
|
||||
command.Name == "rmdirs" ||
|
||||
command.Name == "mkdir" ||
|
||||
command.Name == "touch" ||
|
||||
command.Name == "md5sum" ||
|
||||
command.Name == "sha1sum" ||
|
||||
command.Name == "sha256sum" ||
|
||||
command.Name == "size" ||
|
||||
command.Name == "stat" ||
|
||||
command.Name == "version" {
|
||||
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path</code>
|
||||
} else {
|
||||
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path dest:path</code>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-3 bg-gray-50 text-gray-600 rounded-lg border border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 text-sm">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>This command doesn't have any specific flags.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Helper function to render appropriate input based on flag data type
|
||||
templ renderFlagInput(flag db.RcloneCommandFlag) {
|
||||
if flag.DataType == "int" {
|
||||
<input
|
||||
type="number"
|
||||
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
disabled
|
||||
/>
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
disabled
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
} else if flag.DataType == "float" {
|
||||
<input
|
||||
type="number"
|
||||
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
step="0.01"
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
disabled
|
||||
/>
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
disabled
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
} else {
|
||||
<!-- Default to text input for string and other types -->
|
||||
<input
|
||||
type="text"
|
||||
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
disabled
|
||||
/>
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
disabled
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -1,109 +1,79 @@
|
||||
package destination
|
||||
|
||||
templ FTPDestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_host"
|
||||
id="dest_host"
|
||||
x-model="destHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="ftp.example.com"/>
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="ftp.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||
<div>
|
||||
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
name="dest_port"
|
||||
id="dest_port"
|
||||
x-model="destPort"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
value="21"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="21"/>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="21" min="1" max="65535" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||
<div>
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="destination_path"
|
||||
id="destination_path"
|
||||
x-model="destinationPath"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/destination"/>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div>
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_user"
|
||||
id="dest_user"
|
||||
x-model="destUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="dest_password"
|
||||
id="dest_password"
|
||||
x-model="destPassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="dest_passive_mode" name="dest_passive_mode" x-model="destPassiveMode"
|
||||
class="sr-only peer" :value="destPassiveMode ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
id="dest_passive_mode"
|
||||
name="dest_passive_mode"
|
||||
type="checkbox"
|
||||
x-model="destPassiveMode"
|
||||
:value="destPassiveMode ? 'true' : 'false'"
|
||||
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="dest_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
||||
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/remote/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,175 +1,106 @@
|
||||
package destination
|
||||
|
||||
templ GoogleDriveDestinationForm() {
|
||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
|
||||
if(value) {
|
||||
destClientId = '';
|
||||
destClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="mb-6">
|
||||
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="useBuiltinAuthDest"
|
||||
:value="useBuiltinAuthDest ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Use rclone's built-in Google authentication (recommended)
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
||||
<div>
|
||||
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Drive OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Drive OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="!useBuiltinAuthDest">
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="dest_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Google Drive Folder ID (optional)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Folder ID to use as the root (leave empty for "My Drive")
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthDest" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="dest_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Shared/Team Drive ID (optional)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
ID of the Shared Drive / Team Drive to use
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthDest" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Path within Google Drive (e.g., /backup)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Path within the Drive where files will be uploaded
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/in/drive" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dest_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Specific Google Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<template x-if="useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>To use Google Drive with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Drive API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="dest_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Team Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared team drive. Leave empty for personal drive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,216 +1,188 @@
|
||||
package destination
|
||||
|
||||
templ GooglePhotosDestinationForm() {
|
||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthDest', value => {
|
||||
if(value) {
|
||||
destClientId = '';
|
||||
destClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="mb-6">
|
||||
<label for="use_builtin_auth_dest" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="use_builtin_auth_dest" name="use_builtin_auth_dest" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="useBuiltinAuthDest"
|
||||
:value="useBuiltinAuthDest ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="useBuiltinAuthDest ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Use rclone's built-in Google authentication (recommended)
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDest', value => {
|
||||
if(value) {
|
||||
destClientId = '';
|
||||
destClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
||||
<div>
|
||||
<label for="dest_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="dest_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthDest" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="dest_read_only" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="dest_read_only" name="dest_read_only" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="destReadOnly"
|
||||
:value="destReadOnly ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="destReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Read-only mode
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Only request read-only access to your photos
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="dest_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Only include photos uploaded after this year
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="dest_read_only" name="dest_read_only" x-model="destReadOnly"
|
||||
class="sr-only peer" :value="destReadOnly ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only request read-only access to your photos
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="dest_include_archived" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="dest_include_archived" name="dest_include_archived" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="destIncludeArchived"
|
||||
:value="destIncludeArchived ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="destIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Include archived media
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Include archived photos and videos in media listings
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="dest_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only include photos uploaded after this year
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Album path (e.g., album/my-photos)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Path within Google Photos where files will be uploaded.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="dest_include_archived" name="dest_include_archived" x-model="destIncludeArchived"
|
||||
class="sr-only peer" :value="destIncludeArchived ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Include archived photos and videos in media listings
|
||||
</p>
|
||||
|
||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Album path (e.g., album/my-photos)" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path within Google Photos to download files from
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<template x-if="useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>To use Google Photos with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Photos API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Required</h3>
|
||||
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p>All media items uploaded to Google Photos with rclone are stored in full resolution at original quality. These uploads will count towards storage in your Google Account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Information</h3>
|
||||
<div class="mt-1">
|
||||
<template x-if="useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>To use Google Photos with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Photos API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Important Note About Google Photos</h3>
|
||||
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,39 +1,41 @@
|
||||
package destination
|
||||
package destination
|
||||
|
||||
templ LocalDestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
||||
<div class="space-y-4 mt-4">
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" 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>
|
||||
<input
|
||||
type="text"
|
||||
name="destination_path"
|
||||
id="destination_path"
|
||||
x-model="destinationPath"
|
||||
required
|
||||
:class="{ 'border-red-300 dark:border-red-700': destPathValid === false, 'border-green-300 dark:border-green-700': destPathValid === true }"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Local directory path (e.g., /path/to/destination)"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Full path to the local directory where files will be saved
|
||||
</p>
|
||||
<!-- Path validation message -->
|
||||
<template x-if="destPathValid !== null">
|
||||
<p x-show="destPathError" class="mt-1 text-sm" :class="destPathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="destPathError"></p>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary h-10 whitespace-nowrap"
|
||||
@click="checkPath(destinationPath, 'dest')"
|
||||
>
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Check Location
|
||||
</button>
|
||||
type="button"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
@click="checkPath(destinationPath, 'dest')"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
|
||||
</svg>
|
||||
Check Location
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,91 +1,97 @@
|
||||
package destination
|
||||
|
||||
templ MinIODestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_endpoint"
|
||||
id="dest_endpoint"
|
||||
x-model="destEndpoint"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://minio.example.com"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_bucket"
|
||||
id="dest_bucket"
|
||||
x-model="destBucket"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="my-bucket"/>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://minio.example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="destination_path"
|
||||
id="destination_path"
|
||||
x-model="destinationPath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="path/prefix/"/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. If specified, files will be uploaded to this path in the bucket.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your MinIO server including protocol (http:// or https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_access_key"
|
||||
id="dest_access_key"
|
||||
x-model="destAccessKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your MinIO bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_access_key" name="dest_access_key" x-model="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>
|
||||
<input
|
||||
type="password"
|
||||
name="dest_secret_key"
|
||||
id="dest_secret_key"
|
||||
x-model="destSecretKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
</div>
|
||||
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,56 +1,82 @@
|
||||
package destination
|
||||
|
||||
templ NextCloudDestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_host"
|
||||
id="dest_host"
|
||||
x-model="destHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://nextcloud.example.com"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_user"
|
||||
id="dest_user"
|
||||
x-model="destUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://nextcloud.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your NextCloud server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="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>
|
||||
<input
|
||||
type="password"
|
||||
name="dest_password"
|
||||
id="dest_password"
|
||||
x-model="destPassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,91 +1,97 @@
|
||||
package destination
|
||||
|
||||
templ S3DestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_bucket"
|
||||
id="dest_bucket"
|
||||
x-model="destBucket"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="my-bucket"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-globe-americas text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_region"
|
||||
id="dest_region"
|
||||
x-model="destRegion"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="us-east-1"/>
|
||||
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">S3 Path Prefix</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="destination_path"
|
||||
id="destination_path"
|
||||
x-model="destinationPath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="optional/path/prefix/"/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. If specified, files will be uploaded to this path in the bucket.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_access_key"
|
||||
id="dest_access_key"
|
||||
x-model="destAccessKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your S3 bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_access_key" name="dest_access_key" x-model="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>
|
||||
<input
|
||||
type="password"
|
||||
name="dest_secret_key"
|
||||
id="dest_secret_key"
|
||||
x-model="destSecretKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
</div>
|
||||
<input type="hidden" name="dest_secret_key" :value="destSecretKey"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,147 +1,97 @@
|
||||
package destination
|
||||
|
||||
templ SFTPDestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_host"
|
||||
id="dest_host"
|
||||
x-model="destHost"
|
||||
required
|
||||
aria-describedby="dest_host_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="sftp.example.com"/>
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="sftp.example.com" />
|
||||
</div>
|
||||
<p id="dest_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Enter the SFTP server hostname or IP address.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||
<div>
|
||||
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
name="dest_port"
|
||||
id="dest_port"
|
||||
x-model="destPort"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
value="22"
|
||||
aria-describedby="dest_port_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="22"/>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="22" min="1" max="65535" />
|
||||
</div>
|
||||
<p id="dest_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Default SFTP port is 22.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||
<div>
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="destination_path"
|
||||
id="destination_path"
|
||||
x-model="destinationPath"
|
||||
required
|
||||
aria-describedby="destination_path_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/files"/>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p id="destination_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Absolute path to the files on the remote server.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_user"
|
||||
id="dest_user"
|
||||
x-model="destUser"
|
||||
required
|
||||
aria-describedby="dest_user_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||
<div class="flex items-center mb-4">
|
||||
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
</div>
|
||||
<p id="dest_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Username for SFTP authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<select
|
||||
id="dest_auth_type"
|
||||
name="dest_auth_type"
|
||||
x-model="destAuthType"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||
<option value="password">Password</option>
|
||||
<option value="key_file">SSH Key File</option>
|
||||
</select>
|
||||
<div class="flex items-center">
|
||||
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4" x-show="destAuthType === 'password'">
|
||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div x-show="destAuthType === 'password'">
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="dest_password"
|
||||
id="dest_password"
|
||||
x-model="destPassword"
|
||||
x-bind:required="destAuthType === 'password'"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" x-bind:required="destAuthType === 'password'" />
|
||||
</div>
|
||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4" x-show="destAuthType === 'key_file'">
|
||||
<label for="dest_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
||||
<div x-show="destAuthType === 'key'">
|
||||
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_key_file"
|
||||
id="dest_key_file"
|
||||
x-model="destKeyFile"
|
||||
x-bind:required="destAuthType === 'key_file'"
|
||||
aria-describedby="dest_key_file_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/key"/>
|
||||
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destinationKeyFile"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/id_rsa" x-bind:required="destAuthType === 'key'" />
|
||||
</div>
|
||||
<p id="dest_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Absolute path to SSH private key file.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" 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>
|
||||
}
|
||||
@@ -1,111 +1,112 @@
|
||||
package destination
|
||||
|
||||
templ SMBDestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_host"
|
||||
id="dest_host"
|
||||
x-model="destHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="server.example.com"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_share"
|
||||
id="dest_share"
|
||||
x-model="destShare"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="share_name"/>
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="192.168.1.100 or server.example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="destination_path"
|
||||
id="destination_path"
|
||||
x-model="destinationPath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="path\to\destination"/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. Subdirectory within the share.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
IP address or hostname of the SMB/CIFS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_domain"
|
||||
id="dest_domain"
|
||||
x-model="destDomain"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="DOMAIN"/>
|
||||
<input type="text" id="dest_share" name="dest_share" x-model="destShare" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Documents" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. Windows domain for authentication.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of the shared folder on the server (without leading slashes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_user"
|
||||
id="dest_user"
|
||||
x-model="destUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="dest_domain" name="dest_domain" x-model="destDomain"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WORKGROUP or DOMAIN" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="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>
|
||||
<input
|
||||
type="password"
|
||||
name="dest_password"
|
||||
id="dest_password"
|
||||
x-model="destPassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,75 +1,82 @@
|
||||
package destination
|
||||
|
||||
templ WebDAVDestinationForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_host"
|
||||
id="dest_host"
|
||||
x-model="destHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://webdav.example.com/remote.php/webdav/"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="destination_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="destination_path"
|
||||
id="destination_path"
|
||||
x-model="destinationPath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/destination"/>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://webdav.example.com" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. Relative to the WebDAV URL.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your WebDAV server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="dest_user"
|
||||
id="dest_user"
|
||||
x-model="destUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="webdav_username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="dest_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="mb-6">
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="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>
|
||||
<input
|
||||
type="password"
|
||||
name="dest_password"
|
||||
id="dest_password"
|
||||
x-model="destPassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="dest_password" :value="destPassword"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -54,7 +54,7 @@ templ ProviderForm(formType string, providers []string, isSource bool) {
|
||||
id={fmt.Sprintf("%s_provider", formType)}
|
||||
name={fmt.Sprintf("%s_provider", formType)}
|
||||
x-model={fmt.Sprintf("%sProvider", formType)}
|
||||
class="form-select pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="" disabled selected>Select provider type</option>
|
||||
for _, provider := range providers {
|
||||
<option value={provider}>{providerDisplayName(provider)}</option>
|
||||
|
||||
@@ -1,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
|
||||
|
||||
templ FTPSourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_host"
|
||||
id="source_host"
|
||||
x-model="sourceHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="ftp.example.com"/>
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="ftp.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||
<div>
|
||||
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
name="source_port"
|
||||
id="source_port"
|
||||
x-model="sourcePort"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
value="21"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="21"/>
|
||||
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="21" min="1" max="65535" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||
<div>
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/files"/>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div>
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_user"
|
||||
id="source_user"
|
||||
x-model="sourceUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="source_password"
|
||||
id="source_password"
|
||||
x-model="sourcePassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="source_passive_mode" name="source_passive_mode" x-model="sourcePassiveMode"
|
||||
class="sr-only peer" :value="sourcePassiveMode ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
id="source_passive_mode"
|
||||
name="source_passive_mode"
|
||||
type="checkbox"
|
||||
x-model="sourcePassiveMode"
|
||||
:value="sourcePassiveMode ? 'true' : 'false'"
|
||||
class="focus:ring-primary-500 h-4 w-4 text-primary-600 border-secondary-300 dark:border-secondary-700 rounded">
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="source_passive_mode" class="font-medium text-secondary-700 dark:text-secondary-300">Use Passive Mode</label>
|
||||
<p class="text-secondary-500 dark:text-secondary-400">Enable passive mode for FTP connection</p>
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/remote/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,175 +1,106 @@
|
||||
package source
|
||||
|
||||
templ GoogleDriveSourceForm() {
|
||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
|
||||
if(value) {
|
||||
sourceClientId = '';
|
||||
sourceClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="mb-6">
|
||||
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="useBuiltinAuthSource"
|
||||
:value="useBuiltinAuthSource ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Use rclone's built-in Google authentication (recommended)
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
||||
<div>
|
||||
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Drive OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Drive OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="!useBuiltinAuthSource">
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_drive_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Folder ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Google Drive Folder ID (optional)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Folder ID to use as the root (leave empty for "My Drive")
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthSource" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_team_drive" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Shared/Team Drive ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-users text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Shared/Team Drive ID (optional)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
ID of the Shared Drive / Team Drive to use
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthSource" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Path within Google Drive (e.g., /backup)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Path within the Drive from which files will be transferred
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/in/drive" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p>After saving this configuration, you'll need to authenticate with Google Drive:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Specific Google Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<template x-if="useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>To use Google Drive with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Drive API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="source_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Team Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared team drive. Leave empty for personal drive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,216 +1,188 @@
|
||||
package source
|
||||
|
||||
templ GooglePhotosSourceForm() {
|
||||
<div class="space-y-6" x-init="$watch('useBuiltinAuthSource', value => {
|
||||
if(value) {
|
||||
sourceClientId = '';
|
||||
sourceClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="mb-6">
|
||||
<label for="use_builtin_auth_source" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="use_builtin_auth_source" name="use_builtin_auth_source" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="useBuiltinAuthSource"
|
||||
:value="useBuiltinAuthSource ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="useBuiltinAuthSource ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Use rclone's built-in Google authentication (recommended)
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthSource', value => {
|
||||
if(value) {
|
||||
sourceClientId = '';
|
||||
sourceClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
||||
<div>
|
||||
<label for="source_client_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-id-card text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Photos OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_client_secret" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthSource" class="text-secondary-400 dark:text-secondary-600 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Photos OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
||||
<div class="mb-6">
|
||||
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Photos OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_read_only" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="source_read_only" name="source_read_only" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="sourceReadOnly"
|
||||
:value="sourceReadOnly ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="sourceReadOnly ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Read-only mode
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Only request read-only access to your photos
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Photos OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_start_year" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Start Year (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-calendar text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Only include photos uploaded after this year
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="source_read_only" name="source_read_only" x-model="sourceReadOnly"
|
||||
class="sr-only peer" :value="sourceReadOnly ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only request read-only access to your photos
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_include_archived" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="source_include_archived" name="source_include_archived" type="checkbox"
|
||||
class="sr-only"
|
||||
x-model="sourceIncludeArchived"
|
||||
:value="sourceIncludeArchived ? 'true' : 'false'"
|
||||
/>
|
||||
<div class="block bg-secondary-200 dark:bg-secondary-700 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white dark:bg-secondary-100 w-6 h-6 rounded-full transition"
|
||||
:class="sourceIncludeArchived ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-secondary-700 dark:text-secondary-300 font-medium">
|
||||
Include archived media
|
||||
</div>
|
||||
</label>
|
||||
<p class="mt-1 ml-14 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Include archived photos and videos in media listings
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="source_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only include photos uploaded after this year
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Album Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-images text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Album path (e.g., album/my-photos)" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Path within Google Photos to download files from
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="source_include_archived" name="source_include_archived" x-model="sourceIncludeArchived"
|
||||
class="sr-only peer" :value="sourceIncludeArchived ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Include archived photos and videos in media listings
|
||||
</p>
|
||||
|
||||
<div class="p-4 bg-amber-50 dark:bg-amber-900/50 rounded-lg border border-amber-100 dark:border-amber-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">Authentication Required</h3>
|
||||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p>After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Album path (e.g., album/my-photos)" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path within Google Photos to download files from
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/50 rounded-lg border border-blue-100 dark:border-blue-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">Authentication Information</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
<template x-if="useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>To use Google Photos with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="text-blue-600 dark:text-blue-400 underline">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Photos API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1 py-0.5 rounded">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Required</h3>
|
||||
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/50 rounded-lg border border-yellow-100 dark:border-yellow-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important Note About Google Photos</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p>When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Information</h3>
|
||||
<div class="mt-1">
|
||||
<template x-if="useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>To use Google Photos with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Photos API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Important Note About Google Photos</h3>
|
||||
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,39 +1,41 @@
|
||||
package source
|
||||
|
||||
templ LocalSourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Local Path</label>
|
||||
<div class="space-y-4 mt-4">
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
:class="{ 'border-red-500 dark:border-red-700': sourcePathValid === false, 'border-green-500 dark:border-green-700': sourcePathValid === true }"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
|
||||
<div x-show="sourcePathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span x-text="sourcePathError"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="sourcePathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span x-text="sourcePathError || 'Path is valid'"></span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
required
|
||||
:class="{ 'border-red-300 dark:border-red-700': sourcePathValid === false, 'border-green-300 dark:border-green-700': sourcePathValid === true }"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Local directory path (e.g., /path/to/files)"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Full path to the local directory containing your files
|
||||
</p>
|
||||
<!-- Path validation message -->
|
||||
<template x-if="sourcePathValid !== null">
|
||||
<p x-show="sourcePathError" class="mt-1 text-sm" :class="sourcePathValid ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" x-text="sourcePathError"></p>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary h-10 whitespace-nowrap"
|
||||
@click="checkPath(sourcePath, 'source')"
|
||||
>
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Check Location
|
||||
</button>
|
||||
type="button"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
@click="checkPath(sourcePath, 'source')"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2 inline" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6.5 4.5A5.5 5.5 0 0 1 9 9h2a5.5 5.5 0 0 1 5.5 5.5V17a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-2.5Z"/>
|
||||
</svg>
|
||||
Check Location
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,91 +1,97 @@
|
||||
package source
|
||||
|
||||
templ MinIOSourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_endpoint" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Endpoint URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_endpoint"
|
||||
id="source_endpoint"
|
||||
x-model="sourceEndpoint"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://minio.example.com"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-database text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_bucket"
|
||||
id="source_bucket"
|
||||
x-model="sourceBucket"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="my-bucket"/>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://minio.example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="path/prefix/"/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. If specified, only files in this path will be processed.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your MinIO server including protocol (http:// or https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_access_key"
|
||||
id="source_access_key"
|
||||
x-model="sourceAccessKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your MinIO bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Key</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="minioadmin" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your MinIO Access Key for authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="minioadmin" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your MinIO Secret Key for authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Configuration Note</h3>
|
||||
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="source_secret_key"
|
||||
id="source_secret_key"
|
||||
x-model="sourceSecretKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
</div>
|
||||
<input type="hidden" name="source_secret_key" :value="sourceSecretKey"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,75 +1,82 @@
|
||||
package source
|
||||
|
||||
templ NextCloudSourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-cloud text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_host"
|
||||
id="source_host"
|
||||
x-model="sourceHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://nextcloud.example.com"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/remote.php/dav/files/username/path/to/files"/>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://nextcloud.example.com" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. If left empty, root directory will be used.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your NextCloud server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_user"
|
||||
id="source_user"
|
||||
x-model="sourceUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="nextcloud_username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="NextCloud password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud account password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="remote.php/dav/files/username/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files in NextCloud. Usually starts with "remote.php/dav/files/username/"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Connection Tip</h3>
|
||||
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="source_password"
|
||||
id="source_password"
|
||||
x-model="sourcePassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,100 +1,96 @@
|
||||
package source
|
||||
|
||||
templ S3SourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_bucket" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fab fa-aws text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_bucket"
|
||||
id="source_bucket"
|
||||
x-model="sourceBucket"
|
||||
required
|
||||
aria-describedby="source_bucket_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="my-bucket"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||
</div>
|
||||
<p id="source_bucket_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Enter your S3 bucket name.
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_region" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">AWS Region</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_region"
|
||||
id="source_region"
|
||||
x-model="sourceRegion"
|
||||
required
|
||||
aria-describedby="source_region_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="us-west-2"/>
|
||||
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p id="source_region_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
AWS region for the S3 bucket (e.g., us-west-2).
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your S3 bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path Prefix</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
aria-describedby="source_path_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="path/to/files/"/>
|
||||
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE" />
|
||||
</div>
|
||||
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional path prefix within the bucket (e.g., 'path/to/files/').
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your AWS Access Key ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_access_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Access Key ID</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_access_key"
|
||||
id="source_access_key"
|
||||
x-model="sourceAccessKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE"/>
|
||||
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your AWS Secret Access Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_secret_key" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Secret Access Key</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="source_secret_key"
|
||||
id="source_secret_key"
|
||||
x-model="sourceSecretKey"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Your secret access key"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,147 +1,97 @@
|
||||
package source
|
||||
|
||||
templ SFTPSourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Host</label>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_host"
|
||||
id="source_host"
|
||||
x-model="sourceHost"
|
||||
required
|
||||
aria-describedby="source_host_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="sftp.example.com"/>
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="sftp.example.com" />
|
||||
</div>
|
||||
<p id="source_host_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Enter the SFTP server hostname or IP address.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_port" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Port</label>
|
||||
<div>
|
||||
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-plug text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
name="source_port"
|
||||
id="source_port"
|
||||
x-model="sourcePort"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
value="22"
|
||||
aria-describedby="source_port_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="22"/>
|
||||
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="22" min="1" max="65535" />
|
||||
</div>
|
||||
<p id="source_port_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Default SFTP port is 22.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||
<div>
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
required
|
||||
aria-describedby="source_path_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/files"/>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p id="source_path_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Absolute path to the files on the remote server.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_user"
|
||||
id="source_user"
|
||||
x-model="sourceUser"
|
||||
required
|
||||
aria-describedby="source_user_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||
<div class="flex items-center mb-4">
|
||||
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
</div>
|
||||
<p id="source_user_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Username for SFTP authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_auth_type" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Authentication Type</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<select
|
||||
id="source_auth_type"
|
||||
name="source_auth_type"
|
||||
x-model="sourceAuthType"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||
<option value="password">Password</option>
|
||||
<option value="key_file">SSH Key File</option>
|
||||
</select>
|
||||
<div class="flex items-center">
|
||||
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4" x-show="sourceAuthType === 'password'">
|
||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div x-show="sourceAuthType === 'password'">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="source_password"
|
||||
id="source_password"
|
||||
x-model="sourcePassword"
|
||||
x-bind:required="sourceAuthType === 'password'"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" x-bind:required="sourceAuthType === 'password'" />
|
||||
</div>
|
||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4" x-show="sourceAuthType === 'key_file'">
|
||||
<label for="source_key_file" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Key File</label>
|
||||
<div x-show="sourceAuthType === 'key'">
|
||||
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-file-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_key_file"
|
||||
id="source_key_file"
|
||||
x-model="sourceKeyFile"
|
||||
x-bind:required="sourceAuthType === 'key_file'"
|
||||
aria-describedby="source_key_file_help"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/key"/>
|
||||
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/id_rsa" x-bind:required="sourceAuthType === 'key'" />
|
||||
</div>
|
||||
<p id="source_key_file_help" class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Absolute path to SSH private key file.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/remote/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,111 +1,112 @@
|
||||
package source
|
||||
|
||||
templ SMBSourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Server</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_host"
|
||||
id="source_host"
|
||||
x-model="sourceHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="server.example.com"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_share" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Share Name</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-share-alt text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_share"
|
||||
id="source_share"
|
||||
x-model="sourceShare"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="share_name"/>
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="192.168.1.100 or server.example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="path\to\files"/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. Subdirectory within the share.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
IP address or hostname of the SMB/CIFS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_domain" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Domain</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-building text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_domain"
|
||||
id="source_domain"
|
||||
x-model="sourceDomain"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="DOMAIN"/>
|
||||
<input type="text" id="source_share" name="source_share" x-model="sourceShare" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Documents" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. Windows domain for authentication.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of the shared folder on the server (without leading slashes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_user"
|
||||
id="source_user"
|
||||
x-model="sourceUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="source_domain" name="source_domain" x-model="sourceDomain"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WORKGROUP or DOMAIN" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="smb_username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Username for SMB/CIFS authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="SMB password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Password for SMB/CIFS authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/subfolder" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to a subfolder within the share (Optional). Use forward slashes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Connection Note</h3>
|
||||
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="source_password"
|
||||
id="source_password"
|
||||
x-model="sourcePassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,75 +1,82 @@
|
||||
package source
|
||||
|
||||
templ WebDAVSourceForm() {
|
||||
<div class="sm:col-span-6 space-y-6">
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_host" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-globe text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_host"
|
||||
id="source_host"
|
||||
x-model="sourceHost"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="https://webdav.example.com/remote.php/webdav/"/>
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_path" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Remote Path</label>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-folder text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_path"
|
||||
id="source_path"
|
||||
x-model="sourcePath"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="/path/to/files"/>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://webdav.example.com" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Optional. Relative to the WebDAV URL.
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your WebDAV server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_user" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Username</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-user text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="source_user"
|
||||
id="source_user"
|
||||
x-model="sourceUser"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"/>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="webdav_username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for="source_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="mb-6">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WebDAV password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV account password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files on the WebDAV server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Integration Note</h3>
|
||||
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="source_password"
|
||||
id="source_password"
|
||||
x-model="sourcePassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<input type="hidden" name="source_password" :value="sourcePassword"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+152
-82
@@ -6,98 +6,168 @@ import (
|
||||
|
||||
templ Register(ctx context.Context, errorMessage string) {
|
||||
@LayoutWithContext("Register", ctx) {
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create a new user account
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
Complete the form below to create a new user
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Create Account
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Complete the form below to create a new user account
|
||||
</p>
|
||||
|
||||
<!-- Registration Card -->
|
||||
<div class="max-w-md mx-auto">
|
||||
if errorMessage != "" {
|
||||
<div class="mt-4">
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<span class="block sm:inline">{ errorMessage }</span>
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ errorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form class="mt-8 space-y-6" hx-post="/register" hx-target="body"
|
||||
x-data="{
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email &&
|
||||
this.password &&
|
||||
this.confirmPassword &&
|
||||
this.password === this.confirmPassword;
|
||||
}
|
||||
}"
|
||||
@htmx:before-request="loading = true"
|
||||
@htmx:after-request="loading = false">
|
||||
<input type="hidden" name="remember" value="true" />
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label for="email" class="sr-only">Email address</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
x-model="email"
|
||||
class="form-input rounded-t-md"
|
||||
placeholder="Email address"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="sr-only">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="form-input"
|
||||
placeholder="Password"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="sr-only">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
required
|
||||
x-model="confirmPassword"
|
||||
class="form-input rounded-b-md"
|
||||
placeholder="Confirm Password"/>
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-user-plus text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Registration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Create your account credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
hx-post="/register"
|
||||
hx-target="body"
|
||||
class="space-y-4"
|
||||
x-data="{
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email &&
|
||||
this.password &&
|
||||
this.confirmPassword &&
|
||||
this.password === this.confirmPassword;
|
||||
}
|
||||
}"
|
||||
@htmx:before-request="loading = true"
|
||||
@htmx:after-request="loading = false">
|
||||
|
||||
<div>
|
||||
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Email Address
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-envelope text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
x-model="email"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="your.email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
x-model="password"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
name="confirm_password"
|
||||
x-model="confirmPassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<template x-if="confirmPassword && password !== confirmPassword">
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
Passwords do not match
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Create Account
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Already have an account?
|
||||
<a href="/login" class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6 max-w-md mx-auto">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading">Create User</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
<h4 class="text-sm font-medium mb-1">Account Information</h4>
|
||||
<p>Creating an account will allow you to manage file transfers and configurations. Your email address will only be used for authentication and important notifications related to your transfers.</p>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Already have an account?
|
||||
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,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"
|
||||
|
||||
type TwoFactorBackupCodesData struct {
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
SuccessMessage string
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
SuccessMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">2FA Backup Codes</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">These codes can be used to login if you lose access to your authenticator app</p>
|
||||
</div>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Two-Factor Authentication Backup Codes
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
These codes can be used to login if you lose access to your authenticator app
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.SuccessMessage != "" {
|
||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
||||
<!-- Important warning alert -->
|
||||
<div class="p-4 mb-6 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2"></i>
|
||||
<span class="font-bold">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Success alert -->
|
||||
<div class="p-4 mb-6 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-green-900/50 dark:text-green-400 border border-green-200 dark:border-green-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<!-- Backup codes card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Your Backup Codes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Store these securely for account recovery</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.SuccessMessage != "" {
|
||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
||||
// Show important messages with a different style and icon
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-700 text-yellow-700 dark:text-yellow-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span class="block sm:inline font-bold">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Your Backup Codes</h3>
|
||||
|
||||
<div class="p-4 border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 rounded-lg mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-shield-alt text-amber-600 dark:text-amber-400 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-amber-800 dark:text-amber-300">Security information</h4>
|
||||
<div class="mt-1 text-sm text-amber-700 dark:text-amber-400 space-y-2">
|
||||
<p><strong>These codes are your backup method to access your account.</strong></p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Each code can only be used once.</li>
|
||||
<li>Store these codes in a secure password manager.</li>
|
||||
<li>These codes allow access to your account - keep them safe!</li>
|
||||
<li>For security, you can only view codes immediately after generating them.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Security Alert -->
|
||||
<div class="p-4 mb-6 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800" role="alert">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security information</h4>
|
||||
<p class="font-semibold mb-2">These codes are your backup method to access your account.</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Each code can only be used once.</li>
|
||||
<li>Store these codes in a secure password manager.</li>
|
||||
<li>These codes allow access to your account - keep them safe!</li>
|
||||
<li>For security, you can only view codes immediately after generating them.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
if strings.Contains(code, "REDACTED") {
|
||||
<div class="p-2 bg-secondary-200 dark:bg-secondary-700 rounded font-mono text-sm text-center text-secondary-500 dark:text-secondary-400">
|
||||
{ code }
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center font-bold">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex justify-center space-x-4 mt-6">
|
||||
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
}
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate New Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements that don't contain "REDACTED"
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"SECURITY WARNINGS:\n" +
|
||||
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"* Each code can only be used once.\n" +
|
||||
"* Keep these codes in a secure location like a password manager.\n" +
|
||||
"* Treat these codes with the same security as your password.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes-secure.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-center py-8">
|
||||
<div class="rounded-full bg-secondary-100 dark:bg-secondary-700 p-4 mx-auto w-16 h-16 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-key text-secondary-500 dark:text-secondary-400 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">No Backup Codes Available</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">You don't have any backup codes. Generate new ones for account recovery.</p>
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes">
|
||||
<button type="submit" class="btn-primary">
|
||||
|
||||
<!-- Backup Codes Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
if strings.Contains(code, "REDACTED") {
|
||||
<div class="p-2.5 bg-gray-100 dark:bg-gray-700 rounded font-mono text-sm text-center text-gray-500 dark:text-gray-400">
|
||||
{ code }
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-2.5 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded font-mono text-sm text-center font-bold text-blue-900 dark:text-blue-300">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-3 mt-6">
|
||||
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
|
||||
<button
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg focus:outline-none hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
}
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
|
||||
<button type="submit" class="inline-flex items-center justify-center w-full sm:w-auto text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate Backup Codes
|
||||
Generate New Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="/profile" class="text-primary-600 dark:text-primary-400 hover:text-primary-800 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements that don't contain "REDACTED"
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-blue-50.dark\\:bg-blue-900\\/30')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"SECURITY WARNINGS:\n" +
|
||||
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"* Each code can only be used once.\n" +
|
||||
"* Keep these codes in a secure location like a password manager.\n" +
|
||||
"* Treat these codes with the same security as your password.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes-secure.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
} else {
|
||||
<!-- No backup codes state -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Backup Codes Available</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">You don't have any backup codes. Generate new ones for account recovery.</p>
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes">
|
||||
<button type="submit" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-sync-alt w-4 h-4 mr-2"></i>
|
||||
Generate Backup Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>Backup codes are single-use passwords that allow you to sign in to your account when you don't have access to your authenticator app. Keep these codes in a secure place, such as a password manager or a secure file.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back to Profile Link -->
|
||||
<div class="mt-6 flex justify-start">
|
||||
<a href="/profile" class="inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -8,76 +8,117 @@ type BackupCodeVerifyData struct {
|
||||
|
||||
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
|
||||
@LayoutWithContext("Backup Code Verification", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-amber-100 dark:bg-amber-900 mb-4">
|
||||
<i class="fas fa-key text-amber-600 dark:text-amber-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Backup Code Verification</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter one of your backup codes</p>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Backup Code Verification
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Enter one of your backup codes to verify your identity
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
<!-- Verification Card -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-yellow-600 dark:text-yellow-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Backup Code Authentication
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Alternative to authenticator app</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-6"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Backup Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Backup Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter backup code"
|
||||
required
|
||||
autocomplete="one-time-code"/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input pl-10 w-full"
|
||||
placeholder="Enter backup code"
|
||||
required
|
||||
autocomplete="one-time-code"/>
|
||||
<p class="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-start">
|
||||
<i class="fas fa-exclamation-triangle mr-1.5 mt-0.5 flex-shrink-0"></i>
|
||||
<span>Remember that each backup code can only be used once!</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Remember that each backup code can only be used once!
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="!code.trim() || loading">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Verify
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/verify" class="text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
Use authenticator app instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||
<div class="flex items-start text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-2 mt-0.5 text-blue-500"></i>
|
||||
<p>
|
||||
Backup codes were provided when you set up two-factor authentication. Each code can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="!code.trim() || loading">
|
||||
<span x-show="!loading">Verify</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/verify" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use authenticator app instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6 max-w-md mx-auto">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>Backup codes provide an alternative way to verify your identity when you don't have access to your authenticator app. Each code can only be used once for security reasons.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+190
-125
@@ -11,137 +11,202 @@ type TwoFactorSetupData struct {
|
||||
|
||||
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Set Up Two-Factor Authentication</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enhance your account security with 2FA</p>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Two-Factor Authentication Setup
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Enhance your account security with 2FA by setting up a verification app
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- QR Code Section -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-qrcode text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">1. Scan QR Code</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
<div class="flex justify-center mb-4">
|
||||
<img src={ data.QRCodeURL } alt="QR Code" class="border border-secondary-200 dark:border-secondary-700 rounded-lg p-2 bg-white"/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Can't scan the QR code? Use this code instead:
|
||||
</p>
|
||||
<code class="block mt-2 p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm">
|
||||
{ data.Secret }
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">2. Verify Setup</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/2fa/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input block w-full"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
required/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading">Verify and Enable 2FA</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">3. Save Backup Codes</h3>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
Store these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
for _, code := range data.BackupCodes {
|
||||
<div class="p-2 bg-secondary-100 dark:bg-secondary-700 rounded font-mono text-sm text-center">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-secondary-100.dark\\:bg-secondary-700')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA Backup Codes - Keep these safe!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"Each code can only be used once. Keep these codes safe and secure.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
1. Scan QR Code
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Scan this QR code with your authenticator app</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 flex flex-col items-center">
|
||||
<div class="mb-6 bg-white p-2 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<img src={ data.QRCodeURL } alt="QR Code" class="h-48 w-48"/>
|
||||
</div>
|
||||
<div class="w-full text-center mb-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Can't scan the QR code? Use this code instead:
|
||||
</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 p-3 font-mono text-sm text-gray-800 dark:text-gray-200 break-all">
|
||||
{ data.Secret }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Section -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
2. Verify Setup
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Enter the code from your authenticator app</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/2fa/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="Enter 6-digit code"
|
||||
required/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Verify and Enable 2FA
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
3. Save Backup Codes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Store these backup codes in a safe place</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 mt-1"></i>
|
||||
<div>
|
||||
<span class="font-medium">Important:</span> These codes can be used to access your account if you lose your authenticator device. Each code can only be used once. Keep these codes safe and secure.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 font-mono text-sm text-center text-gray-800 dark:text-gray-200">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-gray-50.dark\\:bg-gray-700.font-mono')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA Backup Codes - Keep these safe!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"Each code can only be used once. Keep these codes safe and secure.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">About Two-Factor Authentication</h4>
|
||||
<p>After enabling 2FA, you'll need to enter a verification code from your authenticator app when signing in. This adds an extra layer of security to your account even if your password is compromised.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,81 +8,114 @@ type TwoFactorVerifyData struct {
|
||||
|
||||
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
|
||||
@LayoutWithContext("Two-Factor Authentication", ctx) {
|
||||
<div class="min-h-screen bg-secondary-50 dark:bg-secondary-900 py-12">
|
||||
<div class="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-shield-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Two-Factor Authentication</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter the code from your authenticator app</p>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col items-center justify-center text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Two-Factor Authentication
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6 text-center">
|
||||
Enter the verification code from your authenticator app to continue
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
<!-- Verification Card -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Authentication Verification
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Secure your account with 2FA</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-6"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Authentication Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Authentication Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="Enter 6-digit code"
|
||||
required/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="form-input pl-10 w-full"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="Enter 6-digit code"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading">Verify</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Verify
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/backup-code" class="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300">
|
||||
Use a backup code instead
|
||||
</a>
|
||||
<div class="text-center">
|
||||
<a href="/login/backup-code" class="text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
Use a backup code instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||
<div class="flex items-start text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-2 mt-0.5 text-blue-500"></i>
|
||||
<p>
|
||||
Lost your device? You can use one of your backup codes instead of the 6-digit code. Backup codes were provided when you set up 2FA.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Lost your device?
|
||||
<br/>
|
||||
You can use one of your backup codes instead of the 6-digit code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6 max-w-md mx-auto">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>Two-factor authentication adds an extra layer of security to your account. After entering your password, you'll need to provide a code from your authenticator app to verify your identity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"strconv"
|
||||
"context"
|
||||
)
|
||||
|
||||
type UsersData struct {
|
||||
@@ -12,88 +12,98 @@ type UsersData struct {
|
||||
|
||||
templ Users(ctx context.Context, data UsersData) {
|
||||
@LayoutWithContext("User Management", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-users mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
User Management
|
||||
</h1>
|
||||
<a href="/admin/users/new" class="btn-primary">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(data.Users) == 0 {
|
||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by adding a new user.</p>
|
||||
<div class="mt-6">
|
||||
<a href="/admin/users/new" class="btn-primary">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-users w-6 h-6 mr-2 text-blue-500"></i>
|
||||
User Management
|
||||
</h1>
|
||||
<a href="/admin/users/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(data.Users) == 0 {
|
||||
<div class="flex p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900 dark:text-yellow-300" role="alert">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 inline w-5 h-5 me-3"></i>
|
||||
<span class="sr-only">Info</span>
|
||||
<div>
|
||||
<span class="font-medium">No users found!</span> Click the "Add User" button to create your first user.
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead class="bg-secondary-50 dark:bg-secondary-800">
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
<i class="fas fa-envelope mr-1"></i> Email
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-envelope w-4 h-4 mr-1"></i>
|
||||
Email
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
<i class="fas fa-user-tag mr-1"></i> Role
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-user-tag w-4 h-4 mr-1"></i>
|
||||
Role
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
<i class="fas fa-calendar-plus mr-1"></i> Created
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-plus w-4 h-4 mr-1"></i>
|
||||
Created
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3">
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-secondary-750 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody>
|
||||
if len(data.Users) == 0 {
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
||||
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
} else {
|
||||
for _, user := range data.Users {
|
||||
<tr class="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
|
||||
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{ user.Email }
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<td class="px-6 py-4">
|
||||
if user.GetIsAdmin() {
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-300">
|
||||
<i class="fas fa-user-shield mr-1"></i> Admin
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||
<i class="fas fa-user-shield w-3 h-3 mr-1"></i> Admin
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||
<i class="fas fa-user mr-1"></i> User
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-user w-3 h-3 mr-1"></i> User
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<td class="px-6 py-4">
|
||||
{ user.CreatedAt.Format("Jan 02, 2006") }
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a
|
||||
href={ templ.SafeURL("/admin/users/" + strconv.Itoa(int(user.ID)) + "/edit") }
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center me-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="btn-danger btn-sm"
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
hx-delete={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
|
||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||
hx-target="body"
|
||||
>
|
||||
<i class="fas fa-trash-alt mr-1"></i> Delete
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -102,15 +112,18 @@ templ Users(ctx context.Context, data UsersData) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-1 text-primary-500"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8">
|
||||
<div class="flex p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900 dark:text-blue-400" role="alert">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 inline w-4 h-4 me-3 mt-0.5"></i>
|
||||
<span class="sr-only">Info</span>
|
||||
<div>
|
||||
User accounts provide secure access to the GoMFT application with role-based permissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,163 +137,125 @@ type UserFormData struct {
|
||||
|
||||
templ UserForm(ctx context.Context, data UserFormData) {
|
||||
@LayoutWithContext("Add User", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-user-plus mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Add New User
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="mb-6 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="p-6">
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="mb-6">
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
hx-post="/admin/users"
|
||||
hx-target="body"
|
||||
action="/admin/users/new"
|
||||
method="POST"
|
||||
x-data="{
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
isAdmin: false,
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email &&
|
||||
this.password &&
|
||||
this.confirmPassword &&
|
||||
this.password === this.confirmPassword;
|
||||
passwordsMatch() {
|
||||
return this.password === this.confirmPassword || this.confirmPassword === '';
|
||||
}
|
||||
}"
|
||||
@htmx:before-request="loading = true"
|
||||
@htmx:after-request="loading = false">
|
||||
>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">User Information</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Create a new user account with appropriate permissions.</p>
|
||||
<h3 class="text-lg font-medium leading-none text-gray-900 dark:text-white">User Information</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Create a new user account with appropriate permissions.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
x-model="email"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="user@example.com" />
|
||||
</div>
|
||||
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
x-model="password"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••" />
|
||||
</div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
id="confirm_password"
|
||||
x-model="confirmPassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••" />
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400" x-show="confirmPassword && password !== confirmPassword">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
<label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirm"
|
||||
id="password_confirm"
|
||||
required
|
||||
x-model="confirmPassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-500" x-show="!passwordsMatch() && confirmPassword !== ''">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
id="is_admin"
|
||||
name="is_admin"
|
||||
type="checkbox"
|
||||
x-model="isAdmin"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="is_admin" class="font-medium text-secondary-700 dark:text-secondary-300">Administrator</label>
|
||||
<p class="text-secondary-500 dark:text-secondary-400">Grant administrative privileges to this user</p>
|
||||
<label for="is_admin" class="font-medium text-gray-900 dark:text-white">Administrator</label>
|
||||
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Grant administrative privileges to this user</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-5">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="/admin/users" class="btn-secondary">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Create User
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/admin/users" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="!passwordsMatch() && confirmPassword !== ''"
|
||||
>
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>User accounts provide secure access to the GoMFT application</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,15 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Set the UID/GID to match your host user for better file permissions
|
||||
# Default is 1000:1000 if not specified
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
# Version information
|
||||
VERSION: ${VERSION:-dev}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
COMMIT: ${COMMIT:-unknown}
|
||||
container_name: gomft
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -22,6 +31,9 @@ services:
|
||||
# - LOG_LEVEL=info
|
||||
networks:
|
||||
- gomft-network
|
||||
# For non-root installs, the container needs to run with the same UID
|
||||
# as the host user to access mounted volumes properly
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
|
||||
networks:
|
||||
gomft-network:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Default username
|
||||
USERNAME=${USERNAME:-gomft}
|
||||
|
||||
# If PUID/PGID env vars are set, update the user's UID/GID
|
||||
if [ -n "${PUID}" ] && [ -n "${PGID}" ]; then
|
||||
echo "🔒 Updating user ${USERNAME} with UID:GID = ${PUID}:${PGID}"
|
||||
|
||||
# Make sure we have directories to work with
|
||||
mkdir -p /app/data /app/backups
|
||||
|
||||
# Check if we're on Alpine (busybox)
|
||||
if grep -q "Alpine" /etc/os-release 2>/dev/null; then
|
||||
echo "Detected Alpine Linux, using busybox usermod/groupmod..."
|
||||
|
||||
# Update group ID first
|
||||
if [ "$(getent group ${USERNAME} | cut -d: -f3)" != "${PGID}" ]; then
|
||||
echo "Updating GID to ${PGID}..."
|
||||
groupmod -g ${PGID} ${USERNAME} || echo "⚠️ Failed to change GID"
|
||||
fi
|
||||
|
||||
# Update user ID
|
||||
if [ "$(id -u ${USERNAME})" != "${PUID}" ]; then
|
||||
echo "Updating UID to ${PUID}..."
|
||||
usermod -u ${PUID} ${USERNAME} || echo "⚠️ Failed to change UID"
|
||||
fi
|
||||
else
|
||||
echo "Non-Alpine system, using standard user management..."
|
||||
# Handle user/group changes with error recovery
|
||||
{
|
||||
# First remove the user (since user has the group as primary group)
|
||||
if getent passwd ${USERNAME} > /dev/null; then
|
||||
echo "Removing existing user ${USERNAME}"
|
||||
userdel ${USERNAME} 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Wait a moment for system to clean up user
|
||||
sleep 1
|
||||
|
||||
# Then remove the group
|
||||
if getent group ${USERNAME} > /dev/null; then
|
||||
echo "Removing existing group ${USERNAME}"
|
||||
groupdel ${USERNAME} 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Recreate group and user in the correct order
|
||||
echo "Creating group ${USERNAME} with GID ${PGID}"
|
||||
groupadd -g ${PGID} ${USERNAME} 2>/dev/null || groupadd ${USERNAME} 2>/dev/null || true
|
||||
|
||||
echo "Creating user ${USERNAME} with UID ${PUID}"
|
||||
useradd -u ${PUID} -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null ||
|
||||
useradd -g ${USERNAME} -s /bin/sh ${USERNAME} 2>/dev/null || true
|
||||
} || {
|
||||
echo "⚠️ Warning: Failed to update UID/GID, continuing with built-in user"
|
||||
}
|
||||
fi
|
||||
|
||||
# Fix ownership of app directories
|
||||
echo "Setting ownership of app directories"
|
||||
chown -R ${USERNAME}:${USERNAME} /app/data /app/backups || echo "⚠️ Warning: Failed to change ownership"
|
||||
|
||||
# Ensure .env file exists and has correct permissions
|
||||
if [ -f /app/.env ]; then
|
||||
echo "Found .env file, setting permissions..."
|
||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||
else
|
||||
echo "No .env file found, creating empty one..."
|
||||
touch /app/.env
|
||||
chown ${USERNAME}:${USERNAME} /app/.env || echo "⚠️ Warning: Failed to change .env ownership"
|
||||
chmod 644 /app/.env || echo "⚠️ Warning: Failed to change .env permissions"
|
||||
fi
|
||||
|
||||
# Run the application as the specified user
|
||||
echo "Starting application as user ${USERNAME}"
|
||||
if command -v su-exec >/dev/null 2>&1; then
|
||||
exec su-exec ${USERNAME} "$@"
|
||||
elif command -v gosu >/dev/null 2>&1; then
|
||||
exec gosu ${USERNAME} "$@"
|
||||
else
|
||||
exec su -m ${USERNAME} -c "$*"
|
||||
fi
|
||||
else
|
||||
# Run as the predefined user (set during build)
|
||||
echo "Starting application with predefined user"
|
||||
exec "$@"
|
||||
fi
|
||||
@@ -12,8 +12,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
@@ -23,7 +22,6 @@ require (
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // 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/reflect2 v1.0.2 // 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/stretchr/objx v0.5.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
|
||||
@@ -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.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.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
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.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=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -12,6 +15,7 @@ import (
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/starfleetcptn/gomft/internal/db/migrations"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -28,6 +32,7 @@ type User struct {
|
||||
TwoFactorSecret string `gorm:"type:varchar(32)"`
|
||||
TwoFactorEnabled bool `gorm:"default:false"`
|
||||
BackupCodes string `gorm:"type:text"` // Comma-separated backup codes
|
||||
Roles []Role `gorm:"many2many:user_roles"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -116,9 +121,13 @@ type TransferConfig struct {
|
||||
UseBuiltinAuthDest *bool `form:"use_builtin_auth_dest"` // For Google and other OAuth services
|
||||
GoogleDriveAuthenticated *bool // Whether Google Drive auth is completed
|
||||
// General fields
|
||||
ArchivePath string `form:"archive_path"`
|
||||
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
|
||||
RcloneFlags string `form:"rclone_flags"`
|
||||
ArchivePath string `form:"archive_path"`
|
||||
ArchiveEnabled *bool `gorm:"default:false" form:"archive_enabled"`
|
||||
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"`
|
||||
SkipProcessedFiles *bool `gorm:"default:true" form:"skip_processed_files"`
|
||||
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
|
||||
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 len(ids) > 0 {
|
||||
j.ConfigID = ids[0]
|
||||
@@ -215,6 +227,8 @@ type JobHistory struct {
|
||||
BytesTransferred int64
|
||||
FilesTransferred int
|
||||
ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// FileMetadata stores information about processed files
|
||||
@@ -241,6 +255,31 @@ type DB struct {
|
||||
*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) {
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(dbPath)
|
||||
@@ -255,7 +294,7 @@ func Initialize(dbPath string) (*DB, error) {
|
||||
}
|
||||
|
||||
// Initialize and run migrations
|
||||
m := migrations.InitMigrations(db)
|
||||
m := migrations.GetMigrations(db)
|
||||
if err := m.Migrate(); err != nil {
|
||||
return nil, fmt.Errorf("failed to run migrations: %v", err)
|
||||
}
|
||||
@@ -263,6 +302,18 @@ func Initialize(dbPath string) (*DB, error) {
|
||||
return &DB{DB: db}, nil
|
||||
}
|
||||
|
||||
// ReopenWithoutMigrations reopens the database connection without running migrations
|
||||
// This should be used when temporarily closing and reopening the database
|
||||
func ReopenWithoutMigrations(dbPath string) (*DB, error) {
|
||||
// Open database connection with modernc.org/sqlite driver
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
return &DB{DB: db}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
sqlDB, err := db.DB.DB()
|
||||
if err != nil {
|
||||
@@ -376,8 +427,25 @@ func (db *DB) GetJob(id uint) (*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
|
||||
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 {
|
||||
@@ -585,7 +653,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||
case "webdav":
|
||||
args := []string{
|
||||
"config", "create", sourceName, "webdav",
|
||||
"url", config.SourceHost,
|
||||
"url", config.SourceEndpoint,
|
||||
"user", config.SourceUser,
|
||||
"pass", config.SourcePassword,
|
||||
"--non-interactive",
|
||||
@@ -600,7 +668,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||
case "nextcloud":
|
||||
args := []string{
|
||||
"config", "create", sourceName, "webdav",
|
||||
"url", config.SourceHost,
|
||||
"url", config.SourceEndpoint,
|
||||
"user", config.SourceUser,
|
||||
"pass", config.SourcePassword,
|
||||
"vendor", "nextcloud",
|
||||
@@ -692,6 +760,9 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||
}
|
||||
|
||||
destName := fmt.Sprintf("dest_%d", config.ID)
|
||||
|
||||
fmt.Println("config", config)
|
||||
|
||||
switch config.DestinationType {
|
||||
case "sftp":
|
||||
args := []string{
|
||||
@@ -807,7 +878,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||
case "webdav":
|
||||
args := []string{
|
||||
"config", "create", destName, "webdav",
|
||||
"url", config.DestHost,
|
||||
"url", config.DestEndpoint,
|
||||
"user", config.DestUser,
|
||||
"pass", config.DestPassword,
|
||||
"--non-interactive",
|
||||
@@ -822,7 +893,7 @@ func (db *DB) GenerateRcloneConfig(config *TransferConfig) error {
|
||||
case "nextcloud":
|
||||
args := []string{
|
||||
"config", "create", destName, "webdav",
|
||||
"url", config.DestHost,
|
||||
"url", config.DestEndpoint,
|
||||
"user", config.DestUser,
|
||||
"pass", config.DestPassword,
|
||||
"vendor", "nextcloud",
|
||||
@@ -965,7 +1036,21 @@ func (db *DB) GetConfigsForJob(jobID uint) ([]TransferConfig, error) {
|
||||
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
|
||||
@@ -1473,3 +1558,555 @@ func (tc *TransferConfig) GetUseBuiltinAuthDest() bool {
|
||||
func (tc *TransferConfig) SetUseBuiltinAuthDest(value bool) {
|
||||
tc.UseBuiltinAuthDest = &value
|
||||
}
|
||||
|
||||
// HasRole checks if the user has a specific role
|
||||
func (u *User) HasRole(roleName string) bool {
|
||||
for _, role := range u.Roles {
|
||||
if role.Name == roleName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPermission checks if the user has a specific permission through any of their roles
|
||||
func (u *User) HasPermission(permission string) bool {
|
||||
for _, role := range u.Roles {
|
||||
if role.HasPermission(permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRoles returns all roles assigned to the user
|
||||
func (u *User) GetRoles(tx *gorm.DB) ([]Role, error) {
|
||||
var roles []Role
|
||||
err := tx.Model(u).Association("Roles").Find(&roles)
|
||||
return roles, err
|
||||
}
|
||||
|
||||
// AssignRole assigns a role to the user
|
||||
func (u *User) AssignRole(tx *gorm.DB, roleID uint, assignedByID uint) error {
|
||||
var role Role
|
||||
if err := tx.First(&role, roleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return role.AssignToUser(tx, u.ID, assignedByID)
|
||||
}
|
||||
|
||||
// UnassignRole removes a role from the user
|
||||
func (u *User) UnassignRole(tx *gorm.DB, roleID uint, unassignedByID uint) error {
|
||||
var role Role
|
||||
if err := tx.First(&role, roleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return role.UnassignFromUser(tx, u.ID, unassignedByID)
|
||||
}
|
||||
|
||||
// Role operations
|
||||
func (db *DB) CreateRole(role *Role) error {
|
||||
return db.Create(role).Error
|
||||
}
|
||||
|
||||
func (db *DB) GetRole(id uint) (*Role, error) {
|
||||
var role Role
|
||||
err := db.First(&role, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetRoleByName(name string) (*Role, error) {
|
||||
var role Role
|
||||
err := db.Where("name = ?", name).First(&role).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateRole(role *Role) error {
|
||||
return db.Save(role).Error
|
||||
}
|
||||
|
||||
func (db *DB) DeleteRole(id uint) error {
|
||||
var role Role
|
||||
if err := db.First(&role, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if role.IsSystemRole() {
|
||||
return errors.New("cannot delete system role")
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := db.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete role assignments
|
||||
if err := tx.Exec("DELETE FROM user_roles WHERE role_id = ?", id).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the role
|
||||
if err := tx.Delete(&role).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (db *DB) ListRoles() ([]Role, error) {
|
||||
var roles []Role
|
||||
err := db.Find(&roles).Error
|
||||
return roles, err
|
||||
}
|
||||
|
||||
func (db *DB) GetUserRoles(userID uint) ([]Role, error) {
|
||||
var user User
|
||||
if err := db.Preload("Roles").First(&user, userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user.Roles, nil
|
||||
}
|
||||
|
||||
// AssignRoleToUser assigns a role to a user
|
||||
func (db *DB) AssignRoleToUser(roleID, userID, assignedByID uint) error {
|
||||
var role Role
|
||||
if err := db.First(&role, roleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := role.AssignToUser(tx, userID, assignedByID); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// UnassignRoleFromUser removes a role from a user
|
||||
func (db *DB) UnassignRoleFromUser(roleID, userID, unassignedByID uint) error {
|
||||
var role Role
|
||||
if err := db.First(&role, roleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if err := tx.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := role.UnassignFromUser(tx, userID, unassignedByID); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package migrations
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
@@ -33,8 +34,25 @@ func InitialSchema() *gormigrate.Migration {
|
||||
return fmt.Errorf("failed to get database path: %v", err)
|
||||
}
|
||||
|
||||
// Create backup file with timestamp
|
||||
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
|
||||
// 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)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -10,6 +15,60 @@ func UpdateGDriveType() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "002_update_gdrive_type",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Check if any tables exist (indicating an existing database)
|
||||
var count int64
|
||||
if err := tx.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&count).Error; err != nil {
|
||||
return fmt.Errorf("failed to check for existing tables: %v", err)
|
||||
}
|
||||
|
||||
// If tables exist, create a backup
|
||||
if count > 0 {
|
||||
// Get the database path
|
||||
sqlDB, err := tx.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying database: %v", err)
|
||||
}
|
||||
|
||||
var seq int
|
||||
var name, dbPath string
|
||||
if err := sqlDB.QueryRow("PRAGMA database_list").Scan(&seq, &name, &dbPath); err != nil {
|
||||
return fmt.Errorf("failed to get database path: %v", err)
|
||||
}
|
||||
|
||||
// Get backup directory from environment variable or use default
|
||||
backupDir := os.Getenv("BACKUP_DIR")
|
||||
if backupDir == "" {
|
||||
backupDir = "/app/backups" // Default Docker path
|
||||
// Check if we're not in Docker
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
backupDir = "backups" // Fallback to local directory
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup directory if it doesn't exist
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %v", err)
|
||||
}
|
||||
|
||||
// Create backup file with timestamp in the backup directory
|
||||
dbFileName := filepath.Base(dbPath)
|
||||
backupFileName := fmt.Sprintf("%s.backup.%s", dbFileName, time.Now().Format("20060102_150405"))
|
||||
backupFile := filepath.Join(backupDir, backupFileName)
|
||||
|
||||
// Read original database
|
||||
data, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read database for backup: %v", err)
|
||||
}
|
||||
|
||||
// Write backup
|
||||
if err := os.WriteFile(backupFile, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to create database backup: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created database backup at: %s\n", backupFile)
|
||||
}
|
||||
|
||||
// Update source_type
|
||||
if err := tx.Exec(`UPDATE transfer_configs SET source_type = 'gdrive' WHERE source_type = 'google_drive'`).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package migrations
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
@@ -34,8 +35,25 @@ func Add2FA() *gormigrate.Migration {
|
||||
return fmt.Errorf("failed to get database path: %v", err)
|
||||
}
|
||||
|
||||
// Create backup file with timestamp
|
||||
backupFile := fmt.Sprintf("%s.backup.%s", dbPath, time.Now().Format("20060102_150405"))
|
||||
// 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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// InitMigrations initializes the migrations
|
||||
func InitMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
||||
migrations := []*gormigrate.Migration{
|
||||
InitialSchema(),
|
||||
UpdateGDriveType(),
|
||||
Add2FA(),
|
||||
}
|
||||
var migrations []*gormigrate.Migration
|
||||
|
||||
// GetMigrations returns all migrations
|
||||
func GetMigrations(db *gorm.DB) *gormigrate.Gormigrate {
|
||||
// Add all migrations in order
|
||||
migrations = append(migrations,
|
||||
InitialSchema(), // 001
|
||||
UpdateGDriveType(), // 002
|
||||
Add2FA(), // 003
|
||||
AddAuditLogs(), // 004
|
||||
AddDefaultRoles(), // 005
|
||||
AddTimestampsToJobHistories(), // 006
|
||||
AddNotificationServices(), // 007
|
||||
AddUserNotifications(), // 008
|
||||
AddRcloneTables(), // 009
|
||||
AddRcloneCommandToConfig(), // 010
|
||||
AddAuthProviders(), // 011
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user