mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-11 17:10:44 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d650247817 | ||
|
|
46df27e8c6 | ||
|
|
bb75bc9400 | ||
|
|
8acdfc216d | ||
|
|
16e7b7e6e5 | ||
|
|
0f46b20fc4 | ||
|
|
3654161245 | ||
|
|
d691fc837e | ||
|
|
b9af8fc051 | ||
|
|
e14d90e37e | ||
|
|
71ec298db5 | ||
|
|
53cf4bc3c5 | ||
|
|
0f9a4cffd2 | ||
|
|
105d223be4 | ||
|
|
7a54a83c8d | ||
|
|
c52267bbb6 | ||
|
|
e93556e537 | ||
|
|
391cfbc50c | ||
|
|
608da9ce1c | ||
|
|
b35174f857 | ||
|
|
ae18cd1a12 | ||
|
|
68dd9fc173 | ||
|
|
f95720758e | ||
|
|
d4c07fdc8e | ||
|
|
28fa65df9d | ||
|
|
c52edb75df | ||
|
|
11fe75dd57 | ||
|
|
ec107ca0ce | ||
|
|
7172d4da90 | ||
|
|
badcbfdb17 | ||
|
|
748d5c0939 | ||
|
|
dd7426a01f | ||
|
|
fde60b7d68 | ||
|
|
297e5ca92b | ||
|
|
405b2e605e | ||
|
|
1f94641dee | ||
|
|
9249b84ddd | ||
|
|
a7cd021ff2 | ||
|
|
8424e97cf8 | ||
|
|
f128eff8de | ||
|
|
db1a0ad5f5 | ||
|
|
8e1321d251 | ||
|
|
ef0829aaa4 | ||
|
|
4d393e17ab | ||
|
|
d35f7bfe53 | ||
|
|
b91b0e8796 | ||
|
|
233f1779d8 | ||
|
|
0cebfcf1f5 | ||
|
|
fb3f834c36 | ||
|
|
30d0725331 | ||
|
|
3140d9e143 | ||
|
|
db6259f8a3 | ||
|
|
49a586db53 | ||
|
|
aa2149e2cf | ||
|
|
8dac5b390c | ||
|
|
faef3c2df9 | ||
|
|
874c7ad69e | ||
|
|
7de4f57fc3 | ||
|
|
9a49d87777 | ||
|
|
73362793ad | ||
|
|
d6bd471eb0 | ||
|
|
1a1df435de | ||
|
|
e031e67b3c | ||
|
|
402072540f | ||
|
|
be19a5efde | ||
|
|
aa04c76e1f | ||
|
|
1981764f80 | ||
|
|
83be259754 | ||
|
|
1995d19c3d | ||
|
|
2258bb1f70 | ||
|
|
4254b529ef | ||
|
|
a2971e3053 | ||
|
|
a7d912aa44 | ||
|
|
3dea48c691 | ||
|
|
ef4bee88b8 | ||
|
|
08d5f0edfc | ||
|
|
1d43a7582d | ||
|
|
5e69aff21e | ||
|
|
0c0c62b1f7 | ||
|
|
d085bac227 | ||
|
|
b1db53b89e | ||
|
|
522699d96a | ||
|
|
cab1de3306 | ||
|
|
15058a7247 | ||
|
|
f1fd33c619 | ||
|
|
d68a80bd78 | ||
|
|
8607f2098a | ||
|
|
0275de0f07 | ||
|
|
a954023800 | ||
|
|
f11c03e22e | ||
|
|
64323dd9ee | ||
|
|
d5b0a686e0 | ||
|
|
558e81c7e8 | ||
|
|
d6fa0c1603 | ||
|
|
d1967b4402 | ||
|
|
3193bf5111 | ||
|
|
a8b4588ecb | ||
|
|
ed81b2c9be | ||
|
|
16b234b2d3 | ||
|
|
5eac95818e | ||
|
|
6d0c215c38 | ||
|
|
31410984cb | ||
|
|
4b625b7120 | ||
|
|
38461c7b7d | ||
|
|
6545d26a93 | ||
|
|
dfbcb021f5 | ||
|
|
db55c92909 | ||
|
|
fcf6ee25a8 | ||
|
|
3b9ff3b6a8 | ||
|
|
43f1772038 | ||
|
|
bcf55fb973 | ||
|
|
db4d0e018f | ||
|
|
bee14b7de0 | ||
|
|
6c96cdbec1 | ||
|
|
339299e08e | ||
|
|
1b1d043d50 | ||
|
|
c3a22333f7 | ||
|
|
ea057f14dc | ||
|
|
0da968fd5a | ||
|
|
7ab3403bca | ||
|
|
9a5ad3ec26 | ||
|
|
8f06776b53 | ||
|
|
248f9cce51 | ||
|
|
9316eb0a53 | ||
|
|
a01edc13cb | ||
|
|
2b2c305ccb | ||
|
|
6f1e877341 | ||
|
|
a6828f4c0e | ||
|
|
6e19df099c | ||
|
|
6503c19898 | ||
|
|
81557840dd | ||
|
|
2017cd8b7d | ||
|
|
f4c8c2121c | ||
|
|
2fbbb41f3f | ||
|
|
30ded57293 | ||
|
|
29b86f16b1 | ||
|
|
c41ca6aca3 |
@@ -0,0 +1,106 @@
|
||||
name: Build and Publish Docker Image DockHub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
manual_version:
|
||||
description: 'Manual version override (leave empty to use git tag)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
# Use github.repository as the default image name
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
# Set the permissions needed for the DockerHub token to push to GHCR
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login to DockerHub Container Registry
|
||||
- name: Log in to DockerHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
# Extract metadata for Docker image
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: starfleetcptn/gomft
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,format=long
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Build and push Docker image
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
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 }}
|
||||
|
||||
+23
-2
@@ -43,10 +43,31 @@ node_modules/
|
||||
Thumbs.db
|
||||
|
||||
# Ignore all Go files in the components directory
|
||||
components/*.go
|
||||
*_templ.go
|
||||
|
||||
# Ignore the data directory
|
||||
data/
|
||||
|
||||
# Ignore .env files
|
||||
.env
|
||||
*.env
|
||||
|
||||
# Ignore the tmp directory
|
||||
tmp/
|
||||
tmp/
|
||||
|
||||
# Ignore the configs directory
|
||||
configs/
|
||||
|
||||
# Ignore the backups directory
|
||||
backups/
|
||||
|
||||
# Ignore Dirs
|
||||
/source/
|
||||
/destination/
|
||||
/archive/
|
||||
|
||||
# Ignore the dist directory
|
||||
static/dist/
|
||||
|
||||
# Ignore binaries
|
||||
gomft
|
||||
+80
-8
@@ -1,7 +1,41 @@
|
||||
# Build frontend assets
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy all files needed for the build first
|
||||
COPY package.json package-lock.json ./
|
||||
COPY build.js ./
|
||||
COPY static/ ./static/
|
||||
COPY tailwind.config.js ./
|
||||
|
||||
# Debug: Show the contents of build.js
|
||||
RUN echo "Contents of build.js:" && cat build.js
|
||||
|
||||
# Remove the build script from postinstall
|
||||
RUN sed -i 's/"postinstall": "npm run build",//' package.json
|
||||
|
||||
# Install dependencies and build with verbose output
|
||||
RUN npm ci && \
|
||||
echo "Building frontend assets..." && \
|
||||
node build.js && \
|
||||
echo "Build complete. Contents of dist:" && \
|
||||
ls -la static/dist/ && \
|
||||
echo "Sample of app.js:" && \
|
||||
head -n 10 static/dist/app.js && \
|
||||
echo "Sample of app.css:" && \
|
||||
head -n 10 static/dist/app.css
|
||||
|
||||
# Go build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Accept build arguments for version information
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_TIME=unknown
|
||||
ARG COMMIT=unknown
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git build-base
|
||||
|
||||
@@ -12,14 +46,29 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy the rest of the source code
|
||||
# Create static directory structure
|
||||
RUN mkdir -p /app/static/dist
|
||||
|
||||
# Copy built frontend assets from frontend-builder BEFORE copying Go source
|
||||
COPY --from=frontend-builder /app/static/dist/ /app/static/dist/
|
||||
|
||||
# Copy the rest of the static files
|
||||
COPY static/ /app/static/
|
||||
|
||||
# Verify static files are in place before Go build
|
||||
RUN echo "Verifying static files before Go build:" && \
|
||||
ls -la /app/static/dist/
|
||||
|
||||
# Now copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Generate template files from .templ files
|
||||
RUN templ generate
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o gomft
|
||||
# Compile the application with version information
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags "-X github.com/starfleetcptn/gomft/components.AppVersion=${VERSION} -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME} -X main.Commit=${COMMIT} -X github.com/starfleetcptn/gomft/components.BuildTime=${BUILD_TIME} -X github.com/starfleetcptn/gomft/components.Commit=${COMMIT}" \
|
||||
-o /app/gomft
|
||||
|
||||
# Install rclone
|
||||
RUN apk add --no-cache curl unzip && \
|
||||
@@ -34,27 +83,50 @@ RUN apk add --no-cache curl unzip && \
|
||||
# Create a smaller runtime image
|
||||
FROM alpine:3.19
|
||||
|
||||
# Add arguments for UID and GID with defaults
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
ARG USERNAME=gomft
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite bash
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite bash shadow su-exec \
|
||||
&& apk add --no-cache --virtual .user-deps \
|
||||
shadow curl xz
|
||||
|
||||
# Create user and group with specified IDs
|
||||
RUN addgroup -g ${GID} ${USERNAME} && \
|
||||
adduser -D -u ${UID} -G ${USERNAME} -s /bin/sh ${USERNAME}
|
||||
|
||||
# Copy the binary from the builder stage
|
||||
COPY --from=builder /app/gomft /app/
|
||||
COPY --from=builder /usr/local/bin/rclone /usr/local/bin/rclone
|
||||
|
||||
# Copy static files and configurations
|
||||
COPY static/ /app/static/
|
||||
# Copy components
|
||||
COPY components/ /app/components/
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data/gomft
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create data and backup directories
|
||||
RUN mkdir -p /app/data /app/backups
|
||||
|
||||
# Create a placeholder .env file with proper permissions
|
||||
RUN touch /app/.env && chmod 644 /app/.env && chown ${USERNAME}:${USERNAME} /app/.env
|
||||
|
||||
# Set executable permissions
|
||||
RUN chmod +x /app/gomft
|
||||
|
||||
# Set ownership of application files
|
||||
RUN chown -R ${USERNAME}:${USERNAME} /app
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 8080
|
||||
|
||||
# Use our entrypoint script
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Run the application
|
||||
CMD ["/app/gomft"]
|
||||
|
||||
@@ -1,44 +1,84 @@
|
||||
# GoMFT - Go Managed File Transfer
|
||||
<p align="center">
|
||||
<img src="static/img/logo.svg" alt="GoMFT Logo" width="200">
|
||||
</p>
|
||||
|
||||
<h1 align="center">GoMFT - Go Managed File Transfer</h1>
|
||||
|
||||
GoMFT is a web-based managed file transfer application built with Go, leveraging rclone for robust file transfer capabilities. It provides a user-friendly interface for configuring, scheduling, and monitoring file transfers across various storage providers.
|
||||
|
||||
> [!WARNING]
|
||||
> This application is actively under development. As such, any aspect of the application—including configurations, data structures, and database fields—may change rapidly and without prior notice. Please review all release notes thoroughly before updating.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Dashboard
|
||||

|
||||
*Dashboard showing active transfers and system status*
|
||||
|
||||

|
||||
*Dashboard dark mode showing active transfers and system status*
|
||||
|
||||
### Configuration Interface
|
||||

|
||||
*Setting up transfer configurations with multiple storage options*
|
||||
|
||||
### Job Management
|
||||

|
||||
*Scheduling transfers with flexible cron expressions*
|
||||
|
||||
### User Mangement
|
||||

|
||||
*Create user accounts and manage them*
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%">
|
||||
<a href="screenshots/dashboard.gomft.png">
|
||||
<img src="screenshots/dashboard.gomft.png" alt="Dashboard Overview" width="100%">
|
||||
</a>
|
||||
<p align="center"><em>Dashboard showing active transfers</em></p>
|
||||
</td>
|
||||
<td width="33%">
|
||||
<a href="screenshots/dashboard.dark.gomft.png">
|
||||
<img src="screenshots/dashboard.dark.gomft.png" alt="Dashboard Dark Mode" width="100%">
|
||||
</a>
|
||||
<p align="center"><em>Dashboard dark mode</em></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Storage Support**: Leverage rclone's extensive support for cloud storage providers:
|
||||
- Google Drive
|
||||
- Google Photos
|
||||
- Amazon S3
|
||||
- MinIO
|
||||
- Backblaze B2
|
||||
- Azure Blob Storage
|
||||
- Google Cloud Storage
|
||||
- NextCloud
|
||||
- WebDAV
|
||||
- SFTP
|
||||
- FTP
|
||||
- SMB/CIFS shares
|
||||
- Local filesystem
|
||||
- And more via rclone
|
||||
- **Webhook Notifications**: Receive real-time notifications of job events:
|
||||
- Configurable webhook URLs
|
||||
- HMAC-SHA256 authentication with secrets
|
||||
- Custom HTTP headers
|
||||
- Selectable events (job success, job failure)
|
||||
- Detailed JSON payload with job information
|
||||
- **Multiple Notification Services**: Get job status updates through various notification channels:
|
||||
- Email notifications with configurable SMTP settings
|
||||
- Webhooks with authentication for custom integrations
|
||||
- Pushbullet notifications with optional device targeting
|
||||
- Ntfy.sh (both public and self-hosted) for simple push notifications
|
||||
- Gotify server integration for self-hosted notifications
|
||||
- Pushover notifications with customizable sounds and priorities
|
||||
- Configurable message templates for all notification types
|
||||
- Event-based triggers (job start, completion, errors)
|
||||
- **Scheduled Transfers**: Configure transfers using cron expressions with flexible scheduling options
|
||||
- **Transfer Monitoring**: Real-time status updates and detailed transfer logs with bytes and files transferred statistics
|
||||
- **File Metadata Tracking**: Complete history and status of all transferred files with detailed information:
|
||||
- Process status (processed, archived, deleted)
|
||||
- File size and hash information
|
||||
- Advanced search and filtering capabilities
|
||||
- Metadata retention for compliance and auditing
|
||||
- Detailed file view with processing timestamps and job association
|
||||
- Powerful filtering by status, filename, job, and date ranges
|
||||
- Advanced search interface with multiple criteria
|
||||
- Bulk management and record deletion capabilities
|
||||
- Responsive design with mobile-friendly interface
|
||||
- **Multi-threaded File Transfers**: Significantly improve performance with concurrent file processing:
|
||||
- Configurable number of concurrent transfers (1-32) per job
|
||||
- Automatic queue management to prevent system overload
|
||||
- Independent configuration for each transfer job
|
||||
- Optimized for both high-volume small files and large file transfers
|
||||
- Maximizes bandwidth utilization for cloud storage providers
|
||||
- **Web Interface**: User-friendly interface for managing transfers, built with Templ components
|
||||
- **File Pattern Matching**: Support for file patterns to filter files during transfers
|
||||
- **File Output Patterns**: Dynamic naming of destination files using patterns with date variables
|
||||
@@ -46,9 +86,21 @@ 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
|
||||
|
||||
@@ -56,8 +108,12 @@ GoMFT is a web-based managed file transfer application built with Go, leveraging
|
||||
- rclone installed and configured
|
||||
- SQLite 3
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Standard Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/starfleetcptn/gomft.git
|
||||
@@ -74,50 +130,208 @@ go mod download
|
||||
go build -o gomft
|
||||
```
|
||||
|
||||
### Docker Installation
|
||||
|
||||
GoMFT is available as a Docker image for quick and easy deployment.
|
||||
|
||||
1. Pull the latest image from Docker Hub:
|
||||
```bash
|
||||
docker pull starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
2. Run the container:
|
||||
|
||||
#### Basic run
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Run with specific user ID and group ID (using environment variables)
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-e PUID=$(id -u) \
|
||||
-e PGID=$(id -g) \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Or specify user IDs directly
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-e PUID=1001 \
|
||||
-e PGID=1001 \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
#### Using a .env file for configuration
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gomft \
|
||||
-p 8080:8080 \
|
||||
-v /path/to/data:/app/data \
|
||||
-v /path/to/backups:/app/backups \
|
||||
-v /path/to/.env:/app/.env \
|
||||
-e PUID=$(id -u) \
|
||||
-e PGID=$(id -g) \
|
||||
starfleetcptn/gomft:latest
|
||||
```
|
||||
3. Access the web interface at `http://localhost:8080`
|
||||
|
||||
#### Docker Compose Example
|
||||
|
||||
For production deployments, you can use Docker Compose with environment variables:
|
||||
|
||||
```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:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
- SERVER_ADDRESS=:8080
|
||||
- DATA_DIR=/app/data
|
||||
- BACKUP_DIR=/app/backups
|
||||
- JWT_SECRET=change_this_to_a_secure_random_string
|
||||
- BASE_URL=http://localhost:8080
|
||||
- GOOGLE_CLIENT_ID=your_google_client_id
|
||||
- GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
- TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
- EMAIL_ENABLED=true
|
||||
- EMAIL_HOST=smtp.example.com
|
||||
- EMAIL_PORT=587
|
||||
- EMAIL_FROM_EMAIL=gomft@example.com
|
||||
- EMAIL_FROM_NAME=GoMFT
|
||||
- EMAIL_ENABLE_TLS=true
|
||||
- EMAIL_REQUIRE_AUTH=true
|
||||
- EMAIL_USERNAME=smtp_username
|
||||
- EMAIL_PASSWORD=smtp_password
|
||||
- LOGS_DIR=/app/data/logs
|
||||
- LOG_MAX_SIZE=10
|
||||
- LOG_MAX_BACKUPS=5
|
||||
- LOG_MAX_AGE=30
|
||||
- LOG_COMPRESS=true
|
||||
- LOG_LEVEL=info
|
||||
# The user directive is no longer needed when using PUID/PGID environment variables
|
||||
```
|
||||
Save this as `docker-compose.yml` and run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
For more information and available tags, visit the [GoMFT Docker Hub page](https://hub.docker.com/r/starfleetcptn/gomft).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
GoMFT uses a configuration file located at `./data/gomft/config.json`. On first run, a default configuration will be created:
|
||||
GoMFT uses an environment file located at `.env` in the root directory of the application. On first run, a default configuration will be created:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_address": ":8080",
|
||||
"data_dir": "./data/gomft",
|
||||
"backup_dir": "./data/gomft/backups",
|
||||
"jwt_secret": "your-secret-key",
|
||||
"base_url": "http://localhost:8080",
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "user@example.com",
|
||||
"password": "your-password",
|
||||
"from_email": "gomft@example.com",
|
||||
"from_name": "GoMFT",
|
||||
"reply_to": "",
|
||||
"enable_tls": true,
|
||||
"require_auth": true
|
||||
}
|
||||
}
|
||||
```ini
|
||||
# Basic configuration
|
||||
SERVER_ADDRESS=:8080
|
||||
DATA_DIR=/app/data
|
||||
BACKUP_DIR=/app/backups
|
||||
JWT_SECRET=change_this_to_a_secure_random_string
|
||||
BASE_URL=http://localhost:8080
|
||||
|
||||
# Google OAuth configuration (optional, for built-in authentication)
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
|
||||
# Email configuration
|
||||
EMAIL_ENABLED=true
|
||||
EMAIL_HOST=smtp.example.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM_EMAIL=gomft@example.com
|
||||
EMAIL_FROM_NAME=GoMFT
|
||||
EMAIL_REPLY_TO=
|
||||
EMAIL_ENABLE_TLS=true
|
||||
EMAIL_REQUIRE_AUTH=true
|
||||
EMAIL_USERNAME=smtp_username
|
||||
EMAIL_PASSWORD=smtp_password
|
||||
|
||||
# Two-Factor Authentication configuration
|
||||
TOTP_ENCRYPTION_KEY=your_32_byte_encryption_key_here
|
||||
|
||||
# UserID and GroupID
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `server_address`: The address and port to run the server on
|
||||
- `data_dir`: Directory for storing application data
|
||||
- `backup_dir`: Directory for storing database backups
|
||||
- `jwt_secret`: Secret key for JWT token generation
|
||||
- `base_url`: Base URL for generating links in emails (e.g., password reset links)
|
||||
- `email`: Email configuration settings for system notifications and password resets
|
||||
- `enabled`: Set to `true` to enable email functionality
|
||||
- `host`: SMTP server hostname
|
||||
- `port`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
||||
- `username`: Username for SMTP authentication
|
||||
- `password`: Password for SMTP authentication
|
||||
- `from_email`: Email address used as sender
|
||||
- `from_name`: Name displayed as the sender
|
||||
- `reply_to`: Optional reply-to email address
|
||||
- `enable_tls`: Set to `true` to use TLS for secure email transmission
|
||||
- `require_auth`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
||||
- `SERVER_ADDRESS`: The address and port to run the server on
|
||||
- `DATA_DIR`: Directory for storing application data (database and configs)
|
||||
- `BACKUP_DIR`: Directory for storing database backups
|
||||
- `JWT_SECRET`: Secret key for JWT token generation
|
||||
- `BASE_URL`: Base URL for generating links in emails (e.g., password reset links)
|
||||
- Google OAuth configuration for built-in authentication:
|
||||
- `GOOGLE_CLIENT_ID`: Your Google OAuth client ID
|
||||
- `GOOGLE_CLIENT_SECRET`: Your Google OAuth client secret
|
||||
- Email configuration settings for system notifications and password resets:
|
||||
- `EMAIL_ENABLED`: Set to `true` to enable email functionality
|
||||
- `EMAIL_HOST`: SMTP server hostname
|
||||
- `EMAIL_PORT`: SMTP server port (usually 587 for TLS, 465 for SSL, or 25 for non-secure)
|
||||
- `EMAIL_USERNAME`: Username for SMTP authentication
|
||||
- `EMAIL_PASSWORD`: Password for SMTP authentication
|
||||
- `EMAIL_FROM_EMAIL`: Email address used as sender
|
||||
- `EMAIL_FROM_NAME`: Name displayed as the sender
|
||||
- `EMAIL_REPLY_TO`: Optional reply-to email address
|
||||
- `EMAIL_ENABLE_TLS`: Set to `true` to use TLS for secure email transmission
|
||||
- `EMAIL_REQUIRE_AUTH`: Set to `true` to require authentication for SMTP connections, or `false` for servers that don't need authentication
|
||||
|
||||
- Two-Factor Authentication (2FA) configuration:
|
||||
- `TOTP_ENCRYPTION_KEY`: Secret key used to encrypt/decrypt TOTP secrets (for 2FA)
|
||||
- Should be exactly 32 bytes (characters) for optimal security
|
||||
- If not set, a default development key will be used (not secure for production)
|
||||
- If shorter than 32 bytes, it will be automatically padded (less secure)
|
||||
- If longer than 32 bytes, it will be truncated to 32 bytes
|
||||
- Example: `TOTP_ENCRYPTION_KEY=abcdefghijklmnopqrstuvwxyz123456`
|
||||
|
||||
|
||||
- SSL/TLS Verification Control:
|
||||
- `SKIP_SSL_VERIFY`: Set to `true` to disable SSL/TLS certificate verification for outgoing connections (e.g., webhooks, email). Use with caution, as this can expose connections to man-in-the-middle attacks. Defaults to `false` (verification enabled).
|
||||
- Example: `SKIP_SSL_VERIFY=true`
|
||||
### Logging Configuration
|
||||
|
||||
GoMFT provides configurable logging with rotation support through the following environment variables:
|
||||
|
||||
- `LOGS_DIR`: Directory where log files are stored (default: `./data/logs`)
|
||||
- `LOG_MAX_SIZE`: Maximum size in megabytes for each log file before rotation (default: `10`)
|
||||
- `LOG_MAX_BACKUPS`: Number of old log files to retain (default: `5`)
|
||||
- `LOG_MAX_AGE`: Maximum number of days to retain old log files (default: `30`)
|
||||
- `LOG_COMPRESS`: Whether to compress rotated log files (default: `true`)
|
||||
- `LOG_LEVEL`: Controls verbosity level of logging (values: `error`, `info`, `debug`, default: `info`)
|
||||
- `error`: Only show errors and critical issues
|
||||
- `info`: Show errors and general operational information (default)
|
||||
- `debug`: Show all messages including detailed debugging information
|
||||
|
||||
Log files contain detailed information about file transfers, job execution, and system operations, which can be useful for troubleshooting and auditing.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -137,6 +351,11 @@ GoMFT uses a configuration file located at `./data/gomft/config.json`. On first
|
||||
- Navigate to "Transfer Configs" section
|
||||
- Configure source and destination locations with connection details
|
||||
- Set file patterns and archive options as needed
|
||||
- Configure performance settings:
|
||||
- Set "Concurrent Transfers" slider to optimize throughput
|
||||
- Use higher values (8-16) for many small files or fast networks
|
||||
- Use lower values (1-4) for large files or limited bandwidth
|
||||
- Consider source/destination system capabilities when setting
|
||||
|
||||
5. Create jobs using your configurations:
|
||||
- Navigate to "Jobs" section
|
||||
@@ -149,29 +368,163 @@ GoMFT uses a configuration file located at `./data/gomft/config.json`. On first
|
||||
- Check detailed transfer history with performance metrics
|
||||
- View job run details including any error messages
|
||||
|
||||
7. Configure webhook notifications:
|
||||
- Enable webhooks in job settings to receive notifications
|
||||
- Provide a valid webhook URL where notifications will be sent
|
||||
- Optionally set a webhook secret for HMAC-SHA256 signature verification
|
||||
- Configure custom HTTP headers in JSON format if needed
|
||||
- Choose notification triggers (job success, job failure, or both)
|
||||
- Test your webhook integration with manual job runs
|
||||
|
||||
8. **Webhook Notifications**:
|
||||
- **Webhook Integration**: Send notifications to external systems when jobs complete
|
||||
- **Secure Authentication**: HMAC-SHA256 signature for webhook verification
|
||||
- **Custom Headers**: Add custom HTTP headers to webhook requests
|
||||
- **Flexible Configuration**: Configure different webhooks for different jobs
|
||||
- **Event Selection**: Choose to send notifications on success, failure, or both
|
||||
- **Detailed Payload**: Rich JSON payload with complete job execution details
|
||||
|
||||
9. **Multiple Notification Services**:
|
||||
- **Pushbullet Integration**: Send notifications to your devices through Pushbullet
|
||||
- Device targeting support for specific device delivery
|
||||
- Customizable title and message templates
|
||||
- API key-based authentication
|
||||
- **Ntfy Integration**: Use public ntfy.sh or self-hosted ntfy server
|
||||
- Topic-based routing of notifications
|
||||
- Priority levels for different job events
|
||||
- Optional username/password authentication for private servers
|
||||
- Customizable title and message templates
|
||||
- **Gotify Integration**: Send notifications to self-hosted Gotify servers
|
||||
- Application token-based authentication
|
||||
- Priority levels (1-10) for different notification importance
|
||||
- Customizable title and message templates
|
||||
- **Pushover Integration**: Professional notification delivery service
|
||||
- Application and user key authentication
|
||||
- Device targeting for selective delivery
|
||||
- Sound selection for different notification types
|
||||
- Priority levels from lowest to emergency
|
||||
- Customizable title and message templates
|
||||
- **Common Features**:
|
||||
- Variable substitution in notification templates
|
||||
- Job data access in templates (status, files, bytes, times)
|
||||
- Event-based filtering (job start, completion, errors)
|
||||
- Success/failure tracking for diagnostic purposes
|
||||
|
||||
10. Manage file metadata:
|
||||
- Navigate to the "Files" section to view all processed files
|
||||
- Use filters to quickly find files by status, job ID, or filename
|
||||
- Click on any file to view detailed metadata including timestamps, size, and hash
|
||||
- Use the advanced search page for complex queries with multiple criteria
|
||||
- Delete file metadata records when no longer needed
|
||||
- View files associated with specific jobs by navigating from the job details
|
||||
|
||||
11. Utilize admin tools (administrators only):
|
||||
- Access the "Admin Tools" section from the navigation menu
|
||||
- View system statistics and server information
|
||||
- Create and manage database backups
|
||||
- Browse and download system log files with the integrated log viewer
|
||||
- Perform database maintenance and optimization tasks
|
||||
- View webhook documentation and integration details
|
||||
|
||||
### User Management
|
||||
|
||||
GoMFT uses a role-based access control system:
|
||||
GoMFT uses a role-based access control system with flexible authentication options:
|
||||
|
||||
- **Administrators**: Can create and manage users, access all features
|
||||
- **Regular Users**: Can manage transfers and view history
|
||||
|
||||
User management features:
|
||||
- Only administrators can create new user accounts
|
||||
- User passwords are securely hashed with bcrypt
|
||||
- Password history tracking prevents reuse of recent passwords
|
||||
- Account lockout after multiple failed login attempts
|
||||
- Self-service password reset via secure email links
|
||||
- JWT-based authentication with tokens
|
||||
- User theme preference settings (light/dark)
|
||||
#### Authentication Options
|
||||
|
||||
1. **Built-in Authentication**:
|
||||
- Email/password login with secure password hashing
|
||||
- JWT-based session management
|
||||
- Password history tracking
|
||||
- Account lockout protection
|
||||
- Self-service password reset
|
||||
|
||||
2. **External Authentication Providers**:
|
||||
- **Authentik Integration**:
|
||||
- Enterprise-grade SSO capabilities
|
||||
- Automatic user provisioning
|
||||
- Role synchronization
|
||||
- Group mapping support
|
||||
- Secure token exchange
|
||||
|
||||
- **OpenID Connect (OIDC)**:
|
||||
- Standard-compliant identity provider support
|
||||
- Automatic user creation and updates
|
||||
- Role mapping from OIDC claims
|
||||
- Multiple provider support
|
||||
- Secure token validation
|
||||
|
||||
- **OAuth2 Providers**:
|
||||
- Google authentication
|
||||
- GitHub integration
|
||||
- Other OAuth2-compliant providers
|
||||
- Custom provider configuration
|
||||
- Automatic profile synchronization
|
||||
|
||||
3. **Security Features**:
|
||||
- Secure password hashing with bcrypt
|
||||
- JWT-based authentication with tokens
|
||||
- Password history tracking prevents reuse
|
||||
- Account lockout after failed attempts
|
||||
- Two-factor authentication support
|
||||
- Session management and timeout
|
||||
- Secure token storage and handling
|
||||
|
||||
4. **User Profile Management**:
|
||||
- Theme preferences (light/dark mode)
|
||||
- Profile information updates
|
||||
- Password change functionality
|
||||
- Two-factor authentication setup
|
||||
- External account linking
|
||||
|
||||
### Two-Factor Authentication (2FA) Implementation
|
||||
|
||||
#### Overview
|
||||
This implementation adds TOTP-based (Time-based One-Time Password) two-factor authentication support to the application, compatible with standard authenticator apps like Google Authenticator, Authy, and others.
|
||||
|
||||
#### Features
|
||||
- TOTP-based authentication (RFC 6238 compliant)
|
||||
- QR code setup for easy enrollment
|
||||
- Backup codes for account recovery
|
||||
- Rate-limited verification attempts
|
||||
- Secure secret storage
|
||||
|
||||
#### Database Changes
|
||||
The following fields have been added to the `users` table:
|
||||
- `two_factor_secret`: Stores the TOTP secret key
|
||||
- `two_factor_enabled`: Boolean flag indicating if 2FA is enabled
|
||||
- `backup_codes`: Stores recovery backup codes
|
||||
|
||||
#### Setup Process
|
||||
1. Navigate to `/profile/2fa/setup`
|
||||
2. Scan the displayed QR code with your authenticator app
|
||||
3. Enter the verification code to confirm setup
|
||||
4. Save your backup codes in a secure location
|
||||
|
||||
#### Login Flow
|
||||
1. Enter email and password as usual
|
||||
2. If 2FA is enabled:
|
||||
- Enter the 6-digit code from your authenticator app
|
||||
- Alternatively, use a backup code if you can't access your authenticator
|
||||
|
||||
#### Security Considerations
|
||||
- The TOTP secrets are encrypted using AES-256-GCM
|
||||
- You must set the `TOTP_ENCRYPTION_KEY` environment variable in production
|
||||
- This key should be 32 bytes (characters) long and kept confidential
|
||||
- Changing this key after users have set up 2FA will invalidate their existing 2FA configurations
|
||||
- For high-security deployments, store this key in a secure vault and inject it at runtime
|
||||
|
||||
### Transfer Configuration Options
|
||||
|
||||
1. **Source/Destination Types**:
|
||||
- Google Drive
|
||||
- Google Photos
|
||||
- Local filesystem
|
||||
- Amazon S3
|
||||
- MinIO (S3-compatible storage)
|
||||
- Backblaze B2
|
||||
- SFTP
|
||||
- FTP
|
||||
- SMB/CIFS shares
|
||||
@@ -180,22 +533,56 @@ User management features:
|
||||
2. **Connection Options**:
|
||||
- Host/server addresses
|
||||
- Authentication (username/password or key files)
|
||||
- OAuth2 authentication for Google services
|
||||
- Port configurations
|
||||
- Cloud credentials (access keys, secret keys)
|
||||
- Bucket and region settings
|
||||
- Custom endpoints
|
||||
- Custom rclone flags
|
||||
|
||||
3. **File Options**:
|
||||
3. **Google Photos Specific Options**:
|
||||
- Read-only mode for safer operations
|
||||
- Start year filter for historical photos
|
||||
- Include/exclude archived media
|
||||
- Album path configuration
|
||||
- Built-in or custom OAuth authentication
|
||||
|
||||
4. **Google Drive Specific Options**:
|
||||
- Folder ID for specific directory access
|
||||
- Team/Shared Drive ID support
|
||||
- Built-in or custom OAuth authentication
|
||||
- Path-based navigation
|
||||
|
||||
5. **File Options**:
|
||||
- File patterns for filtering (e.g., `*.txt`, `data_*.csv`)
|
||||
- Output patterns for dynamic naming
|
||||
- Archive options for transferred files
|
||||
- Skip already processed files to avoid duplicates
|
||||
- Concurrent file transfers (configurable per job)
|
||||
|
||||
4. **Schedule Options**:
|
||||
6. **Performance Options**:
|
||||
- **Multi-threaded File Transfers**: Process multiple files simultaneously for higher throughput
|
||||
- Configurable concurrency level (1-32 concurrent transfers)
|
||||
- Per-job concurrency settings to optimize for different storage types
|
||||
- Automatic transfer queue management to prevent overloading systems
|
||||
- Adaptive processing based on source/destination capabilities
|
||||
|
||||
7. **Schedule Options**:
|
||||
- Cron expressions for flexible scheduling
|
||||
- Manual execution
|
||||
- Enable/disable schedules
|
||||
|
||||
8. **Notification Options**:
|
||||
- **Email Notifications**: Receive job status updates via email
|
||||
- **Webhook Notifications**: Integration with external systems
|
||||
- **Pushbullet**: Push notifications to your devices
|
||||
- **Ntfy**: Simple push notifications via ntfy.sh
|
||||
- **Gotify**: Self-hosted notification server integration
|
||||
- **Pushover**: Professional notification service
|
||||
- Configure event triggers (start, complete, error)
|
||||
- Customize notification message templates
|
||||
- Selective notification based on job status
|
||||
|
||||
### Email Notifications
|
||||
|
||||
GoMFT supports email notifications for various features:
|
||||
@@ -210,9 +597,92 @@ GoMFT supports email notifications for various features:
|
||||
|
||||
To configure email functionality:
|
||||
|
||||
1. Edit the `config.json` file and provide your SMTP server details
|
||||
2. Set `"enabled": true` in the email configuration section
|
||||
3. Ensure the `base_url` setting is configured correctly for your deployment
|
||||
1. Edit the `.env` file and provide your SMTP server details
|
||||
2. Set `EMAIL_ENABLED=true` in the email configuration section
|
||||
3. Ensure the `BASE_URL` setting is configured correctly for your deployment
|
||||
|
||||
### Webhook Integration
|
||||
|
||||
GoMFT can send webhook notifications to external systems when jobs complete. This allows integration with monitoring tools, chat applications, custom notification systems, or workflow automation platforms.
|
||||
|
||||
#### Webhook Payload Structure
|
||||
|
||||
Webhook notifications are sent as HTTP POST requests with a JSON payload containing detailed information about the job execution:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "job_execution",
|
||||
"job_id": 123,
|
||||
"job_name": "Daily Backup",
|
||||
"config_id": 456,
|
||||
"config_name": "S3 to Local Backup",
|
||||
"status": "completed",
|
||||
"start_time": "2023-07-14T15:30:00Z",
|
||||
"end_time": "2023-07-14T15:35:42Z",
|
||||
"duration_seconds": 342,
|
||||
"bytes_transferred": 1048576,
|
||||
"files_transferred": 25,
|
||||
"history_id": 789,
|
||||
"source": {
|
||||
"type": "s3",
|
||||
"path": "my-bucket/data"
|
||||
},
|
||||
"destination": {
|
||||
"type": "local",
|
||||
"path": "/backups/data"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For failed transfers, additional error information is included:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"error_message": "Permission denied accessing destination path"
|
||||
}
|
||||
```
|
||||
|
||||
#### Webhook Authentication
|
||||
|
||||
When a webhook secret is configured, GoMFT signs the payload using HMAC-SHA256 and includes the signature in the `X-Hub-Signature-256` header. To verify the webhook:
|
||||
|
||||
1. Compute the HMAC-SHA256 of the raw request body using your shared secret
|
||||
2. Compare it with the value in the `X-Hub-Signature-256` header
|
||||
3. Process the webhook only if the signatures match
|
||||
|
||||
This ensures that webhook requests are authentic and haven't been tampered with.
|
||||
|
||||
### Admin Tools
|
||||
|
||||
GoMFT provides a comprehensive set of administrative tools for system management and monitoring:
|
||||
|
||||
#### Log Viewer
|
||||
|
||||
The Admin Tools panel includes an integrated log viewer with the following features:
|
||||
|
||||
- **Log File Browser**: View a list of all available log files in the system
|
||||
- **Real-time Log Viewing**: View log file contents directly in the web interface
|
||||
- **Refresh Function**: Update the log list and content with the latest information
|
||||
- **User-friendly Interface**: Clean, readable presentation with custom scrolling
|
||||
- **Dark Mode Support**: Consistent theming with the rest of the application
|
||||
- **Navigation**: Easily switch between different log files
|
||||
|
||||
This log viewer allows administrators to:
|
||||
- Monitor system activity and diagnose issues without requiring server access
|
||||
- View application logs, scheduler logs, and transfer logs in one place
|
||||
- Track down errors and warning messages in real-time
|
||||
|
||||
#### Database Management
|
||||
|
||||
The Admin Tools interface also includes database management capabilities:
|
||||
- Create and manage database backups
|
||||
- Restore from previous backups
|
||||
- Download backups for safekeeping
|
||||
- View system statistics
|
||||
- Optimize the database with maintenance tools
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
@@ -244,6 +714,7 @@ To configure email functionality:
|
||||
- **Authentication**: JWT (JSON Web Tokens)
|
||||
- **Database**: GORM with SQLite
|
||||
- **File Transfer**: rclone
|
||||
- **Deployment**: Docker containerization and traditional installation
|
||||
|
||||
### Building from Source
|
||||
|
||||
@@ -263,6 +734,8 @@ templ generate
|
||||
air
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
@@ -271,6 +744,112 @@ air
|
||||
4. Push to the branch
|
||||
5. Create a Pull Request
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
GoMFT uses the following directory structure:
|
||||
|
||||
- `/app/data`: Main application data directory
|
||||
- Contains the SQLite database (`gomft.db`)
|
||||
- Contains rclone configurations in `/app/data/configs`
|
||||
- Contains log files in `/app/data/logs`
|
||||
- `/app/backups`: Database backup directory
|
||||
|
||||
When using Docker, you should mount volumes to these locations:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /host/path/data:/app/data # For all application data
|
||||
- /host/path/backups:/app/backups # For database backups
|
||||
```
|
||||
|
||||
These paths can be customized using the environment variables `DATA_DIR`, `BACKUP_DIR`, and `LOGS_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Running as a Non-Root User
|
||||
|
||||
By default, Docker containers run as the root user, which can pose security risks. GoMFT supports running as a non-root user, which is recommended for production environments.
|
||||
|
||||
#### Benefits of Running as Non-Root
|
||||
|
||||
- **Improved Security**: Limits the potential damage if the container is compromised
|
||||
- **Better File Permissions**: Files created by the container will match your host user permissions
|
||||
- **Compliance**: Many security policies and best practices require containers to run as non-root
|
||||
|
||||
#### Methods to Run as Non-Root
|
||||
|
||||
1. **Using PUID/PGID environment variables (recommended)**:
|
||||
```bash
|
||||
# Using current user's ID
|
||||
docker run -e PUID=$(id -u) -e PGID=$(id -g) starfleetcptn/gomft:latest
|
||||
|
||||
# Or in docker-compose.yml
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
```
|
||||
This is the most flexible method as it allows changing the user at runtime without rebuilding the image.
|
||||
|
||||
2. **Using the `--user` flag with Docker run**:
|
||||
```bash
|
||||
docker run --user $(id -u):$(id -g) starfleetcptn/gomft:latest
|
||||
```
|
||||
|
||||
3. **Using Docker Compose with environment variables for `user` directive**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
image: starfleetcptn/gomft:latest
|
||||
user: "${UID:-1000}:${GID:-1000}"
|
||||
```
|
||||
|
||||
4. **Building a custom image with specified UID/GID**:
|
||||
```yaml
|
||||
services:
|
||||
gomft:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
```
|
||||
|
||||
#### Environment Variables for User Management
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PUID` | User ID to run as | Built-in user ID (1000) |
|
||||
| `PGID` | Group ID to run as | Built-in group ID (1000) |
|
||||
| `USERNAME` | Username to use | `gomft` |
|
||||
|
||||
These environment variables allow you to change the user/group IDs at runtime without rebuilding the image.
|
||||
|
||||
#### Volume Permissions
|
||||
|
||||
When mounting volumes, ensure that the directories on the host have appropriate permissions for the container user:
|
||||
|
||||
```bash
|
||||
# Create directories with correct ownership
|
||||
mkdir -p data backups
|
||||
chown -R $(id -u):$(id -g) data backups
|
||||
|
||||
# Or adjust permissions to allow the container user to write
|
||||
mkdir -p data backups
|
||||
chmod -R 777 data backups # Less secure, but easier for testing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details
|
||||
[MIT License](LICENSE) - see the full license terms
|
||||
|
||||
The GoMFT logo is licensed under the Creative Commons Attribution 4.0 International Public License.
|
||||
|
||||
The gopher design is from https://github.com/egonelbre/gophers.
|
||||
|
||||
The original Go gopher was designed by Renee French (http://reneefrench.blogspot.com/).
|
||||
|
||||
@@ -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,578 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BackupFile struct {
|
||||
Name string
|
||||
Size string
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type AdminToolsData struct {
|
||||
JobHistoryCount int
|
||||
DatabaseSize string
|
||||
LastBackupTime *time.Time
|
||||
BackupCount int
|
||||
SystemUptime string
|
||||
ActiveJobs int
|
||||
TotalConfigs int
|
||||
TotalJobs int
|
||||
TotalUsers int
|
||||
DatabasePath string
|
||||
BackupPath string
|
||||
MaintenanceMessage string
|
||||
BackupFiles []BackupFile
|
||||
}
|
||||
|
||||
// Dialog component for confirmation dialogs
|
||||
templ Dialog(id string, title string, message string, confirmClass string, confirmText string, formId string, targetAction string) {
|
||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ title }
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4 text-center">
|
||||
<p class="text-secondary-700 dark:text-secondary-300">
|
||||
{ message }
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-end space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
|
||||
Cancel
|
||||
</button>
|
||||
if formId != "" {
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
onclick={ submitFormAndHideDialog(formId, id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-target={ targetAction }
|
||||
onclick={ hideDialog(id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
script hideDialog(id string) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
}
|
||||
|
||||
script submitFormAndHideDialog(formId string, dialogId string) {
|
||||
document.getElementById(formId).submit();
|
||||
document.getElementById(dialogId).classList.add("hidden");
|
||||
}
|
||||
|
||||
script showDialog(id string) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Backup dialog component specifically for restore and delete actions
|
||||
templ BackupActionDialog(id string, title string, message string, confirmClass string, confirmText string, action string, backupName string) {
|
||||
<div id={ id } class="hidden fixed inset-0 bg-secondary-900/50 dark:bg-secondary-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4 overflow-hidden">
|
||||
<div class="px-6 pt-5 pb-3 text-center">
|
||||
<div class="flex justify-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-5xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ title }
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4 text-center">
|
||||
<p class="text-secondary-700 dark:text-secondary-300">
|
||||
{ message }
|
||||
</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 flex justify-center space-x-3">
|
||||
<button type="button" class="btn-secondary" onclick={ hideDialog(id) }>
|
||||
Cancel
|
||||
</button>
|
||||
if action == "restore" {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-warning"
|
||||
hx-post={ fmt.Sprintf("/admin/restore-database/%s", backupName) }
|
||||
hx-swap="none"
|
||||
onclick={ hideDialog(id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
} else if action == "delete" {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
hx-delete={ fmt.Sprintf("/admin/delete-backup/%s", backupName) }
|
||||
hx-target="closest tr"
|
||||
hx-swap="delete"
|
||||
onclick={ hideDialog(id) }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AdminTools(ctx context.Context, data AdminToolsData) {
|
||||
@LayoutWithContext("Admin Tools", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-tools mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
Admin Tools
|
||||
</h1>
|
||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<span id="current-date" class="font-medium"></span>
|
||||
<script>
|
||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if data.MaintenanceMessage != "" {
|
||||
<div class="mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-4 rounded">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
{ data.MaintenanceMessage }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- System Overview -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||
<i class="fas fa-chart-line mr-2 text-primary-500"></i>
|
||||
System Overview
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-database text-blue-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
Database Size
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ data.DatabaseSize }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-history text-green-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
Job History Records
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.JobHistoryCount) }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-clock text-purple-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
System Uptime
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ data.SystemUptime }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-tasks text-orange-500 text-2xl"></i>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 truncate">
|
||||
Active Jobs
|
||||
</dt>
|
||||
<dd>
|
||||
<div class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.ActiveJobs) }
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Tools Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Backup & Restore -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-save mr-2 text-primary-500"></i>
|
||||
Backup & Restore
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between text-sm text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
if data.LastBackupTime != nil {
|
||||
<span>Last backup: { data.LastBackupTime.Format("Jan 02, 2006 15:04:05") }</span>
|
||||
} else {
|
||||
<span>Last backup: Never</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<button id="backup-form"
|
||||
hx-post="/admin/backup-database"
|
||||
hx-swap="none"
|
||||
hx-indicator="#backup-indicator"
|
||||
hx-on::after-request="htmx.trigger('#refresh-backups-trigger', 'click')"
|
||||
class="btn-primary w-full flex items-center justify-center">
|
||||
<i class="fas fa-database mr-2"></i>
|
||||
<span>Backup Database</span>
|
||||
<div id="backup-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Hidden refresh trigger -->
|
||||
<button id="refresh-backups-trigger"
|
||||
class="hidden"
|
||||
hx-get="/admin/refresh-backups"
|
||||
hx-target="#backups-container"></button>
|
||||
|
||||
<form id="export-configs-form" action="/admin/export-configs" method="GET">
|
||||
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||
<i class="fas fa-file-export mr-2"></i>
|
||||
<span>Export All Configurations</span>
|
||||
<div id="export-configs-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form id="export-jobs-form" action="/admin/export-jobs" method="GET">
|
||||
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||
<i class="fas fa-file-export mr-2"></i>
|
||||
<span>Export All Jobs</span>
|
||||
<div id="export-jobs-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
|
||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">Restore Database</h4>
|
||||
<form id="restore-form" hx-post="/admin/restore-database" hx-encoding="multipart/form-data" hx-swap="none" hx-indicator="#restore-indicator">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-grow">
|
||||
<label class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Select Backup File
|
||||
</label>
|
||||
<input type="file" name="backup_file" class="block w-full text-sm text-secondary-500 dark:text-secondary-400
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-medium
|
||||
file:bg-primary-50 file:text-primary-700
|
||||
dark:file:bg-primary-900 dark:file:text-primary-300
|
||||
hover:file:bg-primary-100 dark:hover:file:bg-primary-800
|
||||
focus:outline-none" required />
|
||||
</div>
|
||||
<button type="submit" class="btn-warning flex-shrink-0 flex items-center justify-center h-10">
|
||||
<div class="flex items-center justify-center">
|
||||
<i class="fas fa-upload mr-1"></i>
|
||||
<span>Restore</span>
|
||||
<div id="restore-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Warning: This will replace your current database. Make sure to backup first!
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Tools -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-wrench mr-2 text-primary-500"></i>
|
||||
Maintenance Tools
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body space-y-4">
|
||||
<!-- Clear Job History Button and Dialog -->
|
||||
@Dialog("clear-job-dialog", "Clear Job History", "Are you sure you want to clear all job history? This cannot be undone.", "btn-danger", "Clear History", "purge-form", "")
|
||||
<form id="purge-form" hx-post="/admin/clear-job-history" hx-swap="none" hx-indicator="#clear-history-indicator">
|
||||
<button type="button" class="btn-danger w-full flex items-center justify-center" onclick={ showDialog("clear-job-dialog") }>
|
||||
<i class="fas fa-trash-alt mr-2"></i>
|
||||
<span>Clear Job History</span>
|
||||
<div id="clear-history-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-4 mt-4">
|
||||
<form id="vacuum-form" hx-post="/admin/vacuum-database" hx-swap="none" hx-indicator="#vacuum-indicator">
|
||||
<button type="submit" class="btn-secondary w-full flex items-center justify-center">
|
||||
<i class="fas fa-compress-alt mr-2"></i>
|
||||
<span>Optimize Database</span>
|
||||
<div id="vacuum-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
</button>
|
||||
<p class="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
|
||||
Runs VACUUM to optimize the database and reclaim unused space.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Backups -->
|
||||
<div id="backups-container" class="mt-8">
|
||||
@BackupsList(data)
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="mt-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-info-circle mr-2 text-primary-500"></i>
|
||||
System Information
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Database Path</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
|
||||
{ data.DatabasePath }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Backup Directory</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded overflow-auto">
|
||||
{ data.BackupPath }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Users</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.TotalUsers) }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Configurations</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.TotalConfigs) }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Total Jobs</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.TotalJobs) }
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// BackupsList is a separate component for the backups list that can be refreshed via HTMX
|
||||
templ BackupsList(data AdminToolsData) {
|
||||
if len(data.BackupFiles) > 0 {
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||
Available Backups
|
||||
</h3>
|
||||
<button
|
||||
hx-get="/admin/refresh-backups"
|
||||
hx-target="#backups-container"
|
||||
hx-indicator="#refresh-backups-indicator"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
title="Refresh backups list">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="bg-secondary-50 dark:bg-secondary-900 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead>
|
||||
<tr class="bg-secondary-100 dark:bg-secondary-800">
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Name</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Size</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400">Date</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
for _, backup := range data.BackupFiles {
|
||||
<tr class="hover:bg-secondary-100 dark:hover:bg-secondary-800 transition-colors">
|
||||
<td class="px-4 py-2 text-sm text-secondary-900 dark:text-secondary-100">{ backup.Name }</td>
|
||||
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.Size }</td>
|
||||
<td class="px-4 py-2 text-sm text-secondary-500 dark:text-secondary-400">{ backup.ModTime.Format("Jan 02, 2006 15:04:05") }</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/admin/download-backup/%s", backup.Name)) }
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 mr-2"
|
||||
title="Download backup">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
|
||||
<!-- Restore Dialog -->
|
||||
@BackupActionDialog(
|
||||
fmt.Sprintf("restore-dialog-%s", backup.Name),
|
||||
"RESTORE BACKUP",
|
||||
fmt.Sprintf("Are you sure you want to restore the backup '%s'? This will replace your current database.", backup.Name),
|
||||
"btn-warning",
|
||||
"Restore",
|
||||
"restore",
|
||||
backup.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
class="text-yellow-600 hover:text-yellow-700 dark:text-yellow-400 dark:hover:text-yellow-300 mr-2"
|
||||
title="Restore this backup"
|
||||
onclick={ showDialog(fmt.Sprintf("restore-dialog-%s", backup.Name)) }>
|
||||
<i class="fas fa-upload"></i>
|
||||
</button>
|
||||
|
||||
<!-- Delete Dialog -->
|
||||
@BackupActionDialog(
|
||||
fmt.Sprintf("delete-dialog-%s", backup.Name),
|
||||
"DELETE BACKUP",
|
||||
fmt.Sprintf("Are you sure you want to delete the backup '%s'? This cannot be undone.", backup.Name),
|
||||
"btn-danger",
|
||||
"Delete",
|
||||
"delete",
|
||||
backup.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete backup"
|
||||
onclick={ showDialog(fmt.Sprintf("delete-dialog-%s", backup.Name)) }>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||
Available Backups
|
||||
</h3>
|
||||
<button
|
||||
hx-get="/admin/refresh-backups"
|
||||
hx-target="#backups-container"
|
||||
hx-indicator="#refresh-backups-indicator"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
title="Refresh backups list">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span id="refresh-backups-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center py-6">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-secondary-100 dark:bg-secondary-800 flex items-center justify-center mb-4">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500"></i>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-secondary-900 dark:text-secondary-100">No Backups Available</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Create a backup using the "Backup Database" button.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
templ AuthProviderForm(ctx context.Context, provider *db.AuthProvider, isNew bool) {
|
||||
@LayoutWithContext(getPageTitle(isNew), ctx) {
|
||||
<div id="auth-provider-form-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-provider-form-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="p-4 pb-8 w-full">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
if isNew {
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
New Authentication Provider
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Configure a new external authentication source</p>
|
||||
} else {
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Edit Authentication Provider
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Update an existing external authentication source</p>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-4 py-2 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i> Back to Providers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-6">
|
||||
if isNew {
|
||||
<form method="POST" action="/admin/settings/auth-providers" class="space-y-6">
|
||||
@formContent(provider, isNew)
|
||||
</form>
|
||||
} else {
|
||||
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/settings/auth-providers/%d", provider.ID)) } class="space-y-6">
|
||||
<input type="hidden" name="_method" value="PUT" />
|
||||
@formContent(provider, isNew)
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start mb-2">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Make sure to enter the correct callback URL in your external provider's configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Configure attribute mappings to match the fields in your identity provider's user data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateFormFields() {
|
||||
// Hide all provider-specific sections first
|
||||
document.querySelectorAll('.provider-specific-section').forEach(section => {
|
||||
section.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show the relevant section based on selected provider type
|
||||
const providerType = document.getElementById('type').value;
|
||||
|
||||
if (providerType === 'authentik') {
|
||||
document.getElementById('authentikSection').style.display = 'block';
|
||||
} else if (providerType === 'oidc') {
|
||||
document.getElementById('oidcSection').style.display = 'block';
|
||||
} else if (providerType === 'saml') {
|
||||
document.getElementById('samlSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function testProviderConnection() {
|
||||
const providerId = window.location.pathname.split('/').slice(-2)[0];
|
||||
|
||||
const testBtn = event.target;
|
||||
const originalText = testBtn.innerHTML;
|
||||
testBtn.disabled = true;
|
||||
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Testing...';
|
||||
|
||||
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showToast('error', data.error);
|
||||
} else if (data.success) {
|
||||
showToast('success', data.message || 'Connection test successful');
|
||||
} else {
|
||||
showToast('error', 'Connection test failed');
|
||||
}
|
||||
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerHTML = originalText;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showToast('error', 'Connection test failed');
|
||||
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle icon URL preview
|
||||
function updateIconPreview() {
|
||||
const iconUrl = document.getElementById('icon_url').value;
|
||||
const previewImg = document.getElementById('preview-img');
|
||||
const previewPlaceholder = document.getElementById('preview-placeholder');
|
||||
|
||||
if (iconUrl && iconUrl.trim() !== '') {
|
||||
// Create image element if it doesn't exist
|
||||
if (!previewImg) {
|
||||
const img = document.createElement('img');
|
||||
img.id = 'preview-img';
|
||||
img.className = 'max-w-full max-h-full';
|
||||
img.onerror = function() {
|
||||
// If image fails to load, show placeholder
|
||||
this.style.display = 'none';
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'block';
|
||||
} else {
|
||||
const icon = document.createElement('i');
|
||||
icon.id = 'preview-placeholder';
|
||||
icon.className = 'fas fa-exclamation-circle text-red-500 text-xl';
|
||||
document.getElementById('icon-preview').appendChild(icon);
|
||||
}
|
||||
};
|
||||
img.onload = function() {
|
||||
// If image loads successfully, hide placeholder
|
||||
this.style.display = 'block';
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'none';
|
||||
}
|
||||
};
|
||||
document.getElementById('icon-preview').appendChild(img);
|
||||
}
|
||||
|
||||
// Update image source
|
||||
if (previewImg) {
|
||||
previewImg.src = iconUrl;
|
||||
previewImg.style.display = 'block';
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no URL, show placeholder
|
||||
if (previewImg) {
|
||||
previewImg.style.display = 'none';
|
||||
}
|
||||
if (previewPlaceholder) {
|
||||
previewPlaceholder.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the form on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateFormFields();
|
||||
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('auth-provider-form-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('auth-provider-form-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listener for icon URL changes
|
||||
const iconUrlInput = document.getElementById('icon_url');
|
||||
if (iconUrlInput) {
|
||||
iconUrlInput.addEventListener('input', updateIconPreview);
|
||||
// Initial preview
|
||||
updateIconPreview();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
func getPageTitle(isNew bool) string {
|
||||
if isNew {
|
||||
return "New Authentication Provider"
|
||||
}
|
||||
return "Edit Authentication Provider"
|
||||
}
|
||||
|
||||
templ formContent(provider *db.AuthProvider, isNew bool) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="e.g. Authentik SSO"
|
||||
required
|
||||
value={ getValue(provider, "name") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A descriptive name for this authentication provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Provider Type -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider Type <span class="text-red-600">*</span></label>
|
||||
<select id="type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required onchange="updateFormFields()">
|
||||
<option value="">Select a provider type</option>
|
||||
if provider != nil && provider.Type == db.ProviderTypeAuthentik {
|
||||
<option value="authentik" selected>Authentik</option>
|
||||
} else {
|
||||
<option value="authentik">Authentik</option>
|
||||
}
|
||||
if provider != nil && provider.Type == db.ProviderTypeOIDC {
|
||||
<option value="oidc" selected>OpenID Connect (OIDC)</option>
|
||||
} else {
|
||||
<option value="oidc">OpenID Connect (OIDC)</option>
|
||||
}
|
||||
if provider != nil && provider.Type == db.ProviderTypeSAML {
|
||||
<option value="saml" selected>SAML 2.0</option>
|
||||
} else {
|
||||
<option value="saml">SAML 2.0</option>
|
||||
}
|
||||
if provider != nil && provider.Type == db.ProviderTypeOAuth2 {
|
||||
<option value="oauth2" selected>OAuth 2.0</option>
|
||||
} else {
|
||||
<option value="oauth2">OAuth 2.0</option>
|
||||
}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The type of external authentication service</p>
|
||||
</div>
|
||||
|
||||
<!-- Enabled -->
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
if provider == nil || provider.GetEnabled() {
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
checked
|
||||
/>
|
||||
} else {
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
}
|
||||
<label for="enabled" class="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Whether this authentication provider is active and available for login</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
rows="3"
|
||||
placeholder="Optional description of this provider"
|
||||
>
|
||||
if provider != nil {
|
||||
{ provider.Description }
|
||||
}
|
||||
</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Additional information about this authentication provider</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Icon URL -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Provider Icon</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="icon_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="icon_url"
|
||||
name="icon_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://example.com/icon.svg"
|
||||
value={ provider.IconURL }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the provider's icon image (SVG recommended)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon Preview</label>
|
||||
<div class="flex items-center">
|
||||
<div id="icon-preview" class="border border-gray-300 dark:border-gray-700 rounded-lg p-4 flex items-center justify-center w-20 h-20 bg-white dark:bg-gray-800">
|
||||
if provider != nil && provider.IconURL != "" {
|
||||
<img src={ provider.IconURL } class="max-w-full max-h-full" id="preview-img" />
|
||||
} else {
|
||||
<i class="fas fa-image text-gray-400 text-xl" id="preview-placeholder"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Preview of the icon that will be displayed on the login button.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no URL is provided, a default icon will be used based on the provider type.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Connection Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Provider URL -->
|
||||
<div>
|
||||
<label for="provider_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Provider URL <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="url"
|
||||
id="provider_url"
|
||||
name="provider_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://authentik.example.com"
|
||||
required
|
||||
value={ getValue(provider, "provider_url") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The base URL of the authentication provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Client ID -->
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client ID <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="client_id"
|
||||
name="client_id"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
required
|
||||
value={ getValue(provider, "client_id") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client identifier assigned by the authentication provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Client Secret -->
|
||||
<div>
|
||||
<label for="client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Secret
|
||||
if !isNew {
|
||||
<span class="text-gray-500 text-xs font-normal ml-2">(leave empty to keep current)</span>
|
||||
} else {
|
||||
<span class="text-red-600">*</span>
|
||||
}
|
||||
</label>
|
||||
if isNew {
|
||||
<input
|
||||
type="password"
|
||||
id="client_secret"
|
||||
name="client_secret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
} else {
|
||||
<input
|
||||
type="password"
|
||||
id="client_secret"
|
||||
name="client_secret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
/>
|
||||
}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The client secret for authentication with the provider</p>
|
||||
</div>
|
||||
|
||||
<!-- Redirect URL -->
|
||||
<div>
|
||||
<label for="redirect_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Redirect URL <span class="text-red-600">*</span></label>
|
||||
<input
|
||||
type="url"
|
||||
id="redirect_url"
|
||||
name="redirect_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://your-app.example.com/auth/callback"
|
||||
required
|
||||
value={ getRedirectURL(provider) }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The callback URL that will handle the authentication response</p>
|
||||
</div>
|
||||
|
||||
<!-- Scopes -->
|
||||
<div>
|
||||
<label for="scopes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
id="scopes"
|
||||
name="scopes"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="openid profile email"
|
||||
value={ getValue(provider, "scopes") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Space-separated list of scopes to request from the provider</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="authentikSection">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Authentik Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Authentik Tenant ID -->
|
||||
<div>
|
||||
<label for="authentik_tenant" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tenant ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="authentik_tenant"
|
||||
name="authentik_tenant"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="default"
|
||||
value={ getConfigValue(provider, "tenant_id") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Authentik tenant ID (optional, defaults to 'default')</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="oidcSection">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">OpenID Connect Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- OIDC Discovery URL -->
|
||||
<div>
|
||||
<label for="oidc_discovery_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Discovery URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="oidc_discovery_url"
|
||||
name="oidc_discovery_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://provider/.well-known/openid-configuration"
|
||||
value={ getConfigValue(provider, "discovery_url") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the OIDC discovery document</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 provider-specific-section" id="samlSection">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">SAML Settings</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<!-- SAML Metadata URL -->
|
||||
<div>
|
||||
<label for="saml_metadata_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Metadata URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="saml_metadata_url"
|
||||
name="saml_metadata_url"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://provider/metadata.xml"
|
||||
value={ getConfigValue(provider, "metadata_url") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">URL to the SAML metadata XML</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">User Attribute Mapping</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Username Attribute -->
|
||||
<div>
|
||||
<label for="attr_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_username"
|
||||
name="attr_username"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="preferred_username"
|
||||
value={ getAttributeValue(provider, "username") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the username</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Attribute -->
|
||||
<div>
|
||||
<label for="attr_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_email"
|
||||
name="attr_email"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="email"
|
||||
value={ getAttributeValue(provider, "email") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the email address</p>
|
||||
</div>
|
||||
|
||||
<!-- Display Name Attribute -->
|
||||
<div>
|
||||
<label for="attr_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Display Name Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_name"
|
||||
name="attr_name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="name"
|
||||
value={ getAttributeValue(provider, "name") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute to use as the display name</p>
|
||||
</div>
|
||||
|
||||
<!-- Groups Attribute -->
|
||||
<div>
|
||||
<label for="attr_groups" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Groups Attribute</label>
|
||||
<input
|
||||
type="text"
|
||||
id="attr_groups"
|
||||
name="attr_groups"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="groups"
|
||||
value={ getAttributeValue(provider, "groups") }
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The attribute that contains user groups</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<a href="/admin/settings/auth-providers" class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||
Cancel
|
||||
</a>
|
||||
if !isNew {
|
||||
<button type="button" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-blue-700" onclick="testProviderConnection()">
|
||||
<i class="fas fa-check-circle w-4 h-4 mr-2"></i>
|
||||
Test Connection
|
||||
</button>
|
||||
}
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
if isNew {
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create Provider
|
||||
} else {
|
||||
<i class="fas fa-save w-4 h-4 mr-2"></i>
|
||||
Save Changes
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
func getValue(provider *db.AuthProvider, field string) string {
|
||||
if provider == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch field {
|
||||
case "name":
|
||||
return provider.Name
|
||||
case "provider_url":
|
||||
return provider.ProviderURL
|
||||
case "client_id":
|
||||
return provider.ClientID
|
||||
case "redirect_url":
|
||||
return provider.RedirectURL
|
||||
case "scopes":
|
||||
return provider.Scopes
|
||||
case "icon_url":
|
||||
return provider.IconURL
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getRedirectURL(provider *db.AuthProvider) string {
|
||||
if provider == nil || provider.RedirectURL == "" {
|
||||
return fmt.Sprintf("https://%s/auth/callback", "your-app-domain.com")
|
||||
}
|
||||
return provider.RedirectURL
|
||||
}
|
||||
|
||||
func getConfigValue(provider *db.AuthProvider, key string) string {
|
||||
if provider == nil || provider.Config == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(provider.Config), &config); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if value, ok := config[key]; ok {
|
||||
if strValue, ok := value.(string); ok {
|
||||
return strValue
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getAttributeValue(provider *db.AuthProvider, key string) string {
|
||||
if provider == nil || provider.AttributeMapping == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var mapping map[string]string
|
||||
if err := json.Unmarshal([]byte(provider.AttributeMapping), &mapping); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if value, ok := mapping[key]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// formatTime formats a time.Time value as a human-readable string
|
||||
func formatTime(t time.Time) string {
|
||||
return t.Format("Jan 02, 2006 15:04")
|
||||
}
|
||||
|
||||
templ AuthProviders(ctx context.Context, providers []db.AuthProvider) {
|
||||
@LayoutWithContext("Authentication Providers", ctx) {
|
||||
<!-- Toast container for notifications -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
<div id="auth-providers-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="auth-providers-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="p-4 pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-shield w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Authentication Providers
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400">Manage external authentication sources like Authentik, OIDC, SAML, etc.</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/admin/settings/auth-providers/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
New Provider
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(providers) > 0 {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left rtl:text-right">
|
||||
<thead class="text-xs uppercase bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">Name</th>
|
||||
<th scope="col" class="px-6 py-3">Icon</th>
|
||||
<th scope="col" class="px-6 py-3">Type</th>
|
||||
<th scope="col" class="px-6 py-3">Status</th>
|
||||
<th scope="col" class="px-6 py-3">Provider URL</th>
|
||||
<th scope="col" class="px-6 py-3">Last Used</th>
|
||||
<th scope="col" class="px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, provider := range providers {
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{ provider.Name }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center justify-center">
|
||||
if provider.IconURL != "" {
|
||||
<img src={ provider.IconURL } class="w-6 h-6" alt={ provider.Name + " icon" } />
|
||||
} else {
|
||||
if provider.Type == db.ProviderTypeAuthentik {
|
||||
<img src="/static/img/authentik.svg" class="w-6 h-6" alt="Authentik" />
|
||||
} else if provider.Type == db.ProviderTypeOIDC {
|
||||
<img src="/static/img/oidc.svg" class="w-6 h-6" alt="OIDC" />
|
||||
} else if provider.Type == db.ProviderTypeSAML {
|
||||
<img src="/static/img/saml.svg" class="w-6 h-6" alt="SAML" />
|
||||
} else if provider.Type == db.ProviderTypeOAuth2 {
|
||||
<img src="/static/img/oauth2.svg" class="w-6 h-6" alt="OAuth2" />
|
||||
} else {
|
||||
<i class="fas fa-user-shield text-blue-500 text-lg"></i>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 uppercase">
|
||||
{ string(provider.Type) }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
if provider.GetEnabled() {
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Active
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
Disabled
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 max-w-[200px] truncate">
|
||||
{ provider.ProviderURL }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
if provider.LastUsed.Valid {
|
||||
{ formatTime(provider.LastUsed.Time) }
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500">Never</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex gap-2">
|
||||
<a href={ templ.SafeURL("/admin/settings/auth-providers/" + fmt.Sprint(provider.ID) + "/edit") }
|
||||
class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700"
|
||||
title="Edit">
|
||||
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||
data-provider-name={ provider.Name }
|
||||
class="delete-provider-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
title="Delete">
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
data-provider-id={ fmt.Sprint(provider.ID) }
|
||||
onclick="testProvider(this)"
|
||||
class="text-blue-700 bg-blue-100 hover:bg-blue-200 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-blue-700 dark:text-blue-300 dark:hover:bg-blue-600 dark:focus:ring-blue-800"
|
||||
title="Test Connection">
|
||||
<i class="fas fa-check-circle w-3.5 h-3.5 mr-1.5"></i>
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-user-shield text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Authentication Providers</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||
You haven't set up any external authentication providers yet.
|
||||
</p>
|
||||
<a href="/admin/settings/auth-providers/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add First Provider
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start mb-2">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Authentication providers allow users to sign in using external identity providers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-key w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Make sure to configure callback URLs in your provider's settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-user-check w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Test your connections to ensure proper communication with external authentication systems.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Provider Modal using Flowbite style -->
|
||||
<div id="deleteProviderModal" tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">Are you sure you want to delete the provider <strong id="providerNameConfirm"></strong>?</h3>
|
||||
<button
|
||||
type="button"
|
||||
id="confirmDeleteBtn"
|
||||
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center">
|
||||
Delete
|
||||
</button>
|
||||
<button type="button" onclick="closeDeleteModal()" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toast notification function
|
||||
function showToast(type, message) {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass, bgColorClass, textColorClass;
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||
textColorClass = 'text-green-500 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||
textColorClass = 'text-red-500 dark:text-red-200';
|
||||
} else {
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Set inner HTML with appropriate icon and message
|
||||
toast.innerHTML = `
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||
${type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>'}
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay to ensure the DOM has updated
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function confirmDeleteProvider(providerId, providerName) {
|
||||
document.getElementById('providerNameConfirm').textContent = providerName;
|
||||
document.getElementById('confirmDeleteBtn').onclick = () => deleteProvider(providerId);
|
||||
|
||||
// Show the modal
|
||||
document.getElementById('deleteProviderModal').classList.remove('hidden');
|
||||
document.getElementById('deleteProviderModal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteProviderModal').classList.add('hidden');
|
||||
document.getElementById('deleteProviderModal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function deleteProvider(providerId) {
|
||||
fetch(`/admin/settings/auth-providers/${providerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showToast('error', data.error);
|
||||
} else {
|
||||
showToast('success', 'Authentication provider deleted successfully');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
closeDeleteModal();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showToast('error', 'Failed to delete authentication provider');
|
||||
closeDeleteModal();
|
||||
});
|
||||
}
|
||||
|
||||
function testProvider(btn) {
|
||||
const providerId = btn.getAttribute('data-provider-id');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin w-3.5 h-3.5 mr-1.5"></i> Testing...';
|
||||
|
||||
fetch(`/admin/settings/auth-providers/${providerId}/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
showToast('error', data.error);
|
||||
} else if (data.success) {
|
||||
showToast('success', data.message || 'Connection test successful');
|
||||
} else {
|
||||
showToast('error', 'Connection test failed');
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showToast('error', 'Connection test failed');
|
||||
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
// Set dark background color if in dark mode
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('auth-providers-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('auth-providers-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for delete buttons
|
||||
document.querySelectorAll('.delete-provider-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const providerId = this.getAttribute('data-provider-id');
|
||||
const providerName = this.getAttribute('data-provider-name');
|
||||
confirmDeleteProvider(providerId, providerName);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// getProviderIcon returns the appropriate icon for a provider
|
||||
func getProviderIcon(provider db.AuthProvider) templ.Component {
|
||||
// If provider has a custom icon URL, use it
|
||||
if provider.IconURL != "" {
|
||||
return templ.Raw(fmt.Sprintf(`<img src="%s" class="w-5 h-5" alt="%s icon" />`, provider.IconURL, provider.Name))
|
||||
}
|
||||
|
||||
// Otherwise fall back to default icons based on type
|
||||
switch provider.Type {
|
||||
case db.ProviderTypeAuthentik:
|
||||
return templ.Raw(`<img src="/static/img/authentik.svg" class="w-5 h-5" alt="Authentik" />`)
|
||||
case db.ProviderTypeOIDC:
|
||||
return templ.Raw(`<img src="/static/img/oidc.svg" class="w-5 h-5" alt="OIDC" />`)
|
||||
case db.ProviderTypeSAML:
|
||||
return templ.Raw(`<img src="/static/img/saml.svg" class="w-5 h-5" alt="SAML" />`)
|
||||
case db.ProviderTypeOAuth2:
|
||||
return templ.Raw(`<img src="/static/img/oauth2.svg" class="w-5 h-5" alt="OAuth2" />`)
|
||||
default:
|
||||
return templ.Raw(`<i class="fas fa-user-shield text-blue-500"></i>`)
|
||||
}
|
||||
}
|
||||
|
||||
templ AuthProviderButtons(providers []db.AuthProvider) {
|
||||
if len(providers) == 0 {
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No external authentication providers available
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-2 w-full">
|
||||
for _, provider := range providers {
|
||||
if provider.GetEnabled() {
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/auth/provider/%d", provider.ID)) }
|
||||
class="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gray-100 border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span class="flex-shrink-0 w-5 h-5 mr-2.5">
|
||||
@getProviderIcon(provider)
|
||||
</span>
|
||||
<span>{ provider.Name }</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package components contains the UI components for the GoMFT application.
|
||||
// This file serves as a marker for the components package to ensure it's properly recognized by Go.
|
||||
package components
|
||||
+527
-1223
File diff suppressed because it is too large
Load Diff
+468
-44
@@ -6,82 +6,464 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// 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 } 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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 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 = {
|
||||
id: configID,
|
||||
name: configName
|
||||
};
|
||||
|
||||
// Add custom marker to track this deletion
|
||||
window.currentlyDeletingConfig = true;
|
||||
}
|
||||
|
||||
type ConfigsData struct {
|
||||
Configs []db.TransferConfig
|
||||
Error string
|
||||
ErrorDetails string
|
||||
Status string
|
||||
}
|
||||
|
||||
templ Configs(ctx context.Context, data ConfigsData) {
|
||||
@LayoutWithContext("Transfer Configurations", 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-cogs mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<!-- 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");
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Show status messages based on URL parameters
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check for error message
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const errorMsg = urlParams.get('error');
|
||||
const errorDetails = urlParams.get('details');
|
||||
const status = urlParams.get('status');
|
||||
|
||||
if (errorMsg) {
|
||||
let message = errorMsg;
|
||||
if (errorDetails) {
|
||||
message += ": " + errorDetails;
|
||||
}
|
||||
showToast(message, 'error');
|
||||
}
|
||||
|
||||
if (status === 'gdrive_auth_success') {
|
||||
showToast("Google Drive authentication completed successfully", 'success');
|
||||
}
|
||||
|
||||
// Handle modal hide buttons
|
||||
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||
hideButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const modalId = this.getAttribute('data-modal-hide');
|
||||
const modal = document.getElementById(modalId);
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
|
||||
// Check if this is a DELETE request by examining the URL and method
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
|
||||
// Pattern match for config deletions (e.g., /configs/123)
|
||||
if (path && method === 'DELETE' && path.match(/^\/configs\/\d+$/)) {
|
||||
|
||||
// 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
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
|
||||
// Check for config deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerConfigDelete function
|
||||
window.currentlyDeletingConfig ||
|
||||
// Check flag from beforeRequest handler
|
||||
window.isConfigDeleteRequest ||
|
||||
// Check URL pattern directly from this event
|
||||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
|
||||
// If this is a successful delete request, show notification
|
||||
if (isDeleteRequest && event.detail.successful) {
|
||||
|
||||
let configName = "Unknown";
|
||||
|
||||
// Try multiple sources for config name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
configName = event.detail.elt.getAttribute('data-config-name') || configName;
|
||||
}
|
||||
|
||||
if (configName === "Unknown" && window.lastDeletedConfig) {
|
||||
// Fallback to our stored config info
|
||||
configName = window.lastDeletedConfig.name;
|
||||
}
|
||||
|
||||
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
|
||||
document.addEventListener('htmx:responseError', function(event) {
|
||||
|
||||
// Similar logic as success but for errors
|
||||
const isDeleteRequest =
|
||||
window.currentlyDeletingConfig ||
|
||||
window.isConfigDeleteRequest ||
|
||||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/configs\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
if (isDeleteRequest) {
|
||||
|
||||
let configName = "Unknown";
|
||||
|
||||
// Try multiple sources for config name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
configName = event.detail.elt.getAttribute('data-config-name') || configName;
|
||||
}
|
||||
|
||||
if (configName === "Unknown" && window.lastDeletedConfig) {
|
||||
// Fallback to our stored config info
|
||||
configName = window.lastDeletedConfig.name;
|
||||
}
|
||||
|
||||
let errorMsg = `Failed to delete configuration "${configName}"`;
|
||||
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
errorMsg = event.detail.xhr.responseText
|
||||
// error message is a json object
|
||||
const error = JSON.parse(errorMsg);
|
||||
errorMsg = `Error: ${error.error}`;
|
||||
}
|
||||
|
||||
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 id="configs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="configs-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-exchange-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Transfer Configurations
|
||||
</h1>
|
||||
<a href="/configs/new" class="btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<a href="/configs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
New Configuration
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(data.Configs) == 0 {
|
||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-folder-open text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No configurations</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer configuration.</p>
|
||||
<div class="mt-6">
|
||||
<a href="/configs/new" class="btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create First Configuration
|
||||
</a>
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-exchange-alt text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No configurations</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating a new transfer configuration.</p>
|
||||
<a href="/configs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create First Configuration
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, config := range data.Configs {
|
||||
<li>
|
||||
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
{ config.Name }
|
||||
</p>
|
||||
|
||||
<!-- Google Drive Authentication Badge -->
|
||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||
<span class="ml-2 bg-yellow-100 text-yellow-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-yellow-900 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle w-3 h-3 mr-1 inline"></i>
|
||||
Authentication Required
|
||||
</span>
|
||||
}
|
||||
|
||||
<!-- Google Drive Authentication Status Indicator -->
|
||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && config.GetGoogleAuthenticated() {
|
||||
<span class="ml-2 bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<i class="fas fa-check-circle w-3 h-3 mr-1 inline"></i>
|
||||
Authenticated
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="btn-secondary btn-sm">
|
||||
<i class="fas fa-edit mr-1"></i>
|
||||
<!-- Google Drive Authentication Button -->
|
||||
if (config.DestinationType == "gdrive" || config.SourceType == "gdrive" || config.SourceType == "gphotos" || config.DestinationType == "gphotos") && !config.GetGoogleAuthenticated() {
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d/gdrive-auth", config.ID)) } class="text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:ring-4 focus:outline-none focus:ring-yellow-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-yellow-900 dark:text-yellow-300 dark:hover:bg-yellow-800 dark:focus:ring-yellow-800">
|
||||
<i class="fas fa-key w-3.5 h-3.5 mr-1.5"></i>
|
||||
Authenticate
|
||||
</a>
|
||||
}
|
||||
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/configs/%d", config.ID)) } class="text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<!-- Duplicate config button -->
|
||||
<button
|
||||
hx-delete={ fmt.Sprintf("/configs/%d", config.ID) }
|
||||
hx-confirm="Are you sure you want to delete this configuration?"
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML"
|
||||
class="btn-danger btn-sm">
|
||||
<i class="fas fa-trash-alt mr-1"></i>
|
||||
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),
|
||||
"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,
|
||||
config.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -96,14 +478,56 @@ templ Configs(ctx context.Context, data ConfigsData) {
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
||||
Configurations define how files are transferred between systems
|
||||
</p>
|
||||
<!-- Help Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-sm mt-8 p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start mb-2">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-info-circle w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Configurations define how files are transferred between systems</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fab fa-google-drive w-4 h-4 text-blue-500 dark:text-blue-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">Google Drive and Google Photos configurations require authentication. Click the "Authenticate" button to complete setup.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mt-4">
|
||||
<div class="flex items-center h-5">
|
||||
<i class="fas fa-clone w-4 h-4 text-indigo-500 dark:text-indigo-400 mr-2"></i>
|
||||
</div>
|
||||
<div class="ml-2 text-sm">
|
||||
<p class="text-gray-700 dark:text-gray-300">When duplicating configurations, you may need to re-enter sensitive credentials for security reasons. Make sure to edit duplicated configurations to add any required credentials.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('configs-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('configs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
+186
-93
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DashboardData struct {
|
||||
@@ -12,19 +14,45 @@ type DashboardData struct {
|
||||
ActiveTransfers int
|
||||
CompletedToday int
|
||||
FailedTransfers int
|
||||
Configs map[uint]db.TransferConfig
|
||||
RcloneVersion string
|
||||
}
|
||||
|
||||
// GetRcloneVersion executes the rclone --version command and returns the version string
|
||||
func GetRcloneVersion() string {
|
||||
cmd := exec.Command("rclone", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Parse the version from output (typically first line contains "rclone v1.XX.X")
|
||||
outputStr := string(output)
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
if len(lines) > 0 {
|
||||
parts := strings.Split(lines[0], " ")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "v") {
|
||||
return part
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
@LayoutWithContext("Dashboard", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-tachometer-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<div id="dashboard-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-chart-pie w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Dashboard
|
||||
</h1>
|
||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<span id="current-date" class="font-medium"></span>
|
||||
<div class="bg-white dark:bg-gray-800 p-2 px-4 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<span id="current-date" class="font-medium text-gray-700 dark:text-gray-300"></span>
|
||||
<script>
|
||||
document.getElementById('current-date').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
</script>
|
||||
@@ -32,154 +60,199 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-6 flex items-center">
|
||||
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-3 mr-4">
|
||||
<i class="fas fa-exchange-alt text-blue-600 dark:text-blue-300 text-xl"></i>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
<!-- Active Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-blue-600 bg-blue-100 rounded-lg dark:text-blue-300 dark:bg-blue-900">
|
||||
<i class="fas fa-exchange-alt w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Active Transfers</p>
|
||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.ActiveTransfers) }</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.ActiveTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Currently in progress</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-6 flex items-center">
|
||||
<div class="rounded-full bg-green-100 dark:bg-green-900 p-3 mr-4">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-300 text-xl"></i>
|
||||
<!-- Completed Today -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Completed Today</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-green-600 bg-green-100 rounded-lg dark:text-green-300 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Completed Today</p>
|
||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.CompletedToday) }</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.CompletedToday) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Successfully completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="p-6 flex items-center">
|
||||
<div class="rounded-full bg-red-100 dark:bg-red-900 p-3 mr-4">
|
||||
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-300 text-xl"></i>
|
||||
<!-- Failed Transfers -->
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Failed Transfers</h3>
|
||||
<div class="inline-flex items-center justify-center w-10 h-10 text-red-600 bg-red-100 rounded-lg dark:text-red-300 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Failed Transfers</p>
|
||||
<p class="text-2xl font-bold text-secondary-900 dark:text-secondary-100">{ strconv.Itoa(data.FailedTransfers) }</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-4xl font-bold leading-none text-gray-900 dark:text-white">{ strconv.Itoa(data.FailedTransfers) }</span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-baseline text-sm font-medium">
|
||||
<span class="text-gray-500 dark:text-gray-400">Require attention</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-500"></i>
|
||||
<!-- Main Content Area -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<!-- Recent Jobs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Recent Jobs
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-4">
|
||||
if len(data.RecentJobs) == 0 {
|
||||
<div class="py-8 text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-800 mb-4">
|
||||
<i class="fas fa-inbox text-secondary-400 text-3xl"></i>
|
||||
<!-- Empty state with Flowbite styling -->
|
||||
<div class="flex flex-col items-center justify-center py-8 px-4 text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list text-gray-500 dark:text-gray-400 text-3xl"></i>
|
||||
</div>
|
||||
<p class="text-secondary-500 dark:text-secondary-400">No recent jobs found</p>
|
||||
<a href="/jobs/new" class="mt-4 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<span>Create your first job</span>
|
||||
<i class="fas fa-arrow-right ml-1"></i>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No recent jobs found</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Get started by creating your first job.</p>
|
||||
<a href="/jobs/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Create your first job
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="flow-root">
|
||||
<ul role="list" class="-my-5 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, job := range data.RecentJobs {
|
||||
<li class="py-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 px-4 rounded-lg transition-colors">
|
||||
<div class="flex items-center space-x-4">
|
||||
<li class="py-3 sm:py-4 hover:bg-gray-50 dark:hover:bg-gray-700 px-3 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
if job.Status == "completed" {
|
||||
<span class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center dark:bg-green-900">
|
||||
<i class="fas fa-check text-green-600 dark:text-green-300"></i>
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
|
||||
<i class="fas fa-check-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else if job.Status == "failed" {
|
||||
<span class="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center dark:bg-red-900">
|
||||
<i class="fas fa-times text-red-600 dark:text-red-300"></i>
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-red-600 bg-red-100 dark:bg-red-900">
|
||||
<i class="fas fa-exclamation-circle w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
} else {
|
||||
<span class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center dark:bg-blue-900">
|
||||
<i class="fas fa-sync-alt text-blue-600 dark:text-blue-300"></i>
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-blue-600 bg-blue-100 dark:bg-blue-900">
|
||||
<i class="fas fa-tasks w-6 h-6 flex items-center justify-center"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-secondary-900 truncate dark:text-secondary-100">
|
||||
{ job.Job.Config.Name }
|
||||
<div class="flex-1 min-w-0 ms-4">
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-white">
|
||||
{ getConfigNameForHistory(job, data.Configs) }
|
||||
</p>
|
||||
<div class="flex items-center mt-1">
|
||||
<i class="fas fa-clock text-xs text-secondary-500 dark:text-secondary-400 mr-1"></i>
|
||||
<p class="text-sm text-secondary-500 truncate dark:text-secondary-400">
|
||||
<i class="fas fa-clock w-4 h-4 text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||
<p class="text-sm text-gray-500 truncate dark:text-gray-400">
|
||||
Started: { job.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 text-sm leading-5 font-medium rounded-full text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
||||
<i class="fas fa-eye mr-1"></i>
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", job.ID)) }
|
||||
class="inline-flex items-center text-sm font-medium text-blue-600 hover:underline dark:text-blue-500">
|
||||
View
|
||||
<i class="fas fa-arrow-right w-3 h-3 ms-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<a href="/jobs" class="w-full flex justify-center items-center px-4 py-2 border border-secondary-300 shadow-sm text-sm font-medium rounded-lg text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-secondary-800 dark:text-secondary-200 dark:border-secondary-600 dark:hover:bg-secondary-700">
|
||||
<i class="fas fa-list-ul mr-2"></i>
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 mt-4">
|
||||
<a href="/jobs" class="inline-flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list w-4 h-4 mr-2"></i>
|
||||
View all jobs
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-bolt mr-2 text-primary-500"></i>
|
||||
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bolt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0"></i>
|
||||
Quick Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body space-y-4">
|
||||
<a href="/configs/new" class="btn-primary text-center flex items-center justify-center">
|
||||
<i class="fas fa-plus-circle mr-2"></i>
|
||||
<div class="p-4 space-y-4">
|
||||
<a href="/configs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-5 h-5 mr-2"></i>
|
||||
Create New Config
|
||||
</a>
|
||||
<a href="/jobs/new" class="btn-primary text-center flex items-center justify-center">
|
||||
<i class="fas fa-play-circle mr-2"></i>
|
||||
<a href="/jobs/new" class="inline-flex items-center justify-center w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-tasks w-5 h-5 mr-2"></i>
|
||||
Create New Job
|
||||
</a>
|
||||
<a href="/history" class="btn-secondary text-center flex items-center justify-center">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
<a href="/history" class="inline-flex items-center justify-center w-full text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
|
||||
<i class="fas fa-history w-5 h-5 mr-2"></i>
|
||||
View Transfer History
|
||||
</a>
|
||||
|
||||
<!-- System Status Card -->
|
||||
<div class="mt-6 bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-700">
|
||||
<h4 class="text-sm font-medium text-secondary-900 dark:text-secondary-100 mb-2">System Status</h4>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Server</span>
|
||||
<span class="badge badge-success">Online</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Scheduler</span>
|
||||
<span class="badge badge-success">Running</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-secondary-500 dark:text-secondary-400">Database</span>
|
||||
<span class="badge badge-success">Connected</span>
|
||||
<!-- System Status Card with Flowbite design -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm mt-6">
|
||||
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 rounded-t-lg border-b border-gray-200 dark:border-gray-600">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">System Status</h4>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Server</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Scheduler</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Running
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Database</span>
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300">
|
||||
<span class="w-2 h-2 mr-1 bg-green-500 rounded-full"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Rclone</span>
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||
<i class="fas fa-terminal w-3 h-3 mr-1"></i>
|
||||
if data.RcloneVersion != "" {
|
||||
{ data.RcloneVersion }
|
||||
} else {
|
||||
Unknown
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,6 +260,26 @@ templ Dashboard(ctx context.Context, data DashboardData) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('dashboard-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('dashboard-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
package details
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/dialog" // Import dialog package
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils" // Import utils package
|
||||
)
|
||||
|
||||
// FileMetadataDetails renders the details view for a file metadata, matching original structure
|
||||
templ FileMetadataDetails(ctx context.Context, data file_metadata.FileMetadataDetailsData) {
|
||||
@components.LayoutWithContext("File Details", ctx) {
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||
|
||||
<div id="file-details-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
File Details: { data.File.FileName }
|
||||
</h1>
|
||||
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<!-- Card header -->
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
{ data.File.FileName }
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
File ID: { strconv.FormatUint(uint64(data.File.ID), 10) }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="p-4 md:p-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- File Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt mr-2 text-gray-500 dark:text-gray-400"></i> File Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Filename
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.FileName }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Size
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ utils.FormatFileSize(data.File.FileSize) }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Hash
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
if data.File.FileHash != "" {
|
||||
<span class="font-mono">{ data.File.FileHash }</span>
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">Not available</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Status
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(data.File.Status) }>
|
||||
{ data.File.Status }
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Original Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder mr-2 text-yellow-500"></i>
|
||||
<span>{ data.File.OriginalPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Destination Path
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder-open mr-2 text-blue-500"></i>
|
||||
<span>{ data.File.DestinationPath }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Information -->
|
||||
<div>
|
||||
<h6 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cogs mr-2 text-gray-500 dark:text-gray-400"></i> Processing Information
|
||||
</h6>
|
||||
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<tbody>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Job
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", data.File.JobID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
{ data.File.Job.Name }
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Processed Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2 text-gray-500"></i>
|
||||
<span>{ data.File.ProcessedTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Creation Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-plus mr-2 text-green-500"></i>
|
||||
<span>{ data.File.CreationTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Modification Time
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt mr-2 text-purple-500"></i>
|
||||
<span>{ data.File.ModTime.Format("2006-01-02 15:04:05") }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
if data.File.Status == "error" && data.File.ErrorMessage != "" {
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Error
|
||||
</th>
|
||||
<td class="py-3 px-4 break-all bg-white dark:bg-gray-800">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle mt-1 mr-2 text-red-500"></i>
|
||||
<span class="text-red-600 dark:text-red-400">{ data.File.ErrorMessage }</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Created
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.CreatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b dark:border-gray-700">
|
||||
<th scope="row" class="py-3 px-4 font-medium text-gray-900 whitespace-nowrap dark:text-white bg-gray-50 dark:bg-gray-800">
|
||||
Record Updated
|
||||
</th>
|
||||
<td class="py-3 px-4 bg-white dark:bg-gray-800">
|
||||
{ data.File.UpdatedAt.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete dialog component call -->
|
||||
@dialog.FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", data.File.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", data.File.FileName),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800", // Use correct classes
|
||||
"Delete",
|
||||
"delete",
|
||||
data.File.ID,
|
||||
data.File.FileName,
|
||||
"details", // Indicate this is from the details view
|
||||
)
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<a href="/files" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-list mr-2"></i> Back to Files
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", data.File.ID)} }
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
|
||||
<i class="fas fa-trash mr-2"></i> Delete Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('file-details-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('file-details-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
)
|
||||
|
||||
// FileMetadataDialog renders a confirmation dialog for file metadata actions
|
||||
templ FileMetadataDialog(id string, title string, message string, confirmClass string, confirmText string, action string, fileID uint, fileName string, section string) {
|
||||
@utils.FileMetadataJS()
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<!-- Backdrop -->
|
||||
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||
<!-- Modal content -->
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
if action == "delete" {
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||
}
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||
if section == "list" {
|
||||
<button
|
||||
type="button"
|
||||
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
|
||||
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
|
||||
hx-target={ fmt.Sprintf("#file-row-%d", fileID) }
|
||||
hx-swap="delete"
|
||||
data-file-name={ fileName }
|
||||
data-file-id={ fmt.Sprint(fileID) }
|
||||
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
type="button"
|
||||
class="text-white font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-800"
|
||||
hx-delete={ fmt.Sprintf("/files/%d", fileID) }
|
||||
hx-redirect="/files"
|
||||
data-file-name={ fileName }
|
||||
data-file-id={ fmt.Sprint(fileID) }
|
||||
id={ fmt.Sprintf("delete-file-btn-%d", fileID) }
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerFileDelete('%s', %d, '%s')", id, fileID, fileName)} }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||
)
|
||||
|
||||
// FileMetadataList renders the list of file metadata
|
||||
templ FileMetadataList(ctx context.Context, data file_metadata.FileMetadataListData) {
|
||||
@components.LayoutWithContext("Files", ctx) { // Call using components package
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
@utils.FileMetadataJS() // Use capitalized function name
|
||||
|
||||
<div id="list-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Files
|
||||
</h1>
|
||||
<div class="flex gap-3">
|
||||
<a href="/files/search" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-search mr-2"></i> Advanced Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Filter Files</h5>
|
||||
<form
|
||||
hx-get="/files/partial"
|
||||
hx-target="#file-list-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#filter-loading"
|
||||
hx-headers='{"X-HX-Request": "true"}'
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
if data.Job == nil {
|
||||
<div>
|
||||
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job</label>
|
||||
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="md:col-span-3 flex justify-end items-center">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-filter mr-2"></i> Apply Filters
|
||||
</button>
|
||||
<div id="filter-loading" class="htmx-indicator ml-2 flex items-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<!-- Card header -->
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
File List
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing { strconv.FormatInt(int64((data.Page - 1) * data.Limit + 1), 10) } to { strconv.FormatInt(int64(min(data.Page * data.Limit, int(data.TotalCount))), 10) } of { strconv.FormatInt(data.TotalCount, 10) } files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div id="file-list-container" class="p-4 md:p-5">
|
||||
@FileMetadataListPartial(ctx, data, "/files/partial", "#file-list-container")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('list-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('list-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/dialog"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
)
|
||||
|
||||
// Helper function to generate sorting links
|
||||
func sortLink(currentSortBy, currentSortDir, targetSortBy, basePath string, filter file_metadata.FileMetadataFilter, limit int) string {
|
||||
nextSortDir := "asc"
|
||||
if currentSortBy == targetSortBy && currentSortDir == "asc" {
|
||||
nextSortDir = "desc"
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("page", "1")
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
q.Set("sort_by", targetSortBy)
|
||||
q.Set("sort_dir", nextSortDir)
|
||||
if filter.Status != "" {
|
||||
q.Set("status", filter.Status)
|
||||
}
|
||||
if filter.FileName != "" {
|
||||
q.Set("filename", filter.FileName)
|
||||
}
|
||||
if filter.JobID != "" {
|
||||
q.Set("job_id", filter.JobID)
|
||||
}
|
||||
if filter.Hash != "" {
|
||||
q.Set("hash", filter.Hash)
|
||||
}
|
||||
if filter.StartDate != "" {
|
||||
q.Set("start_date", filter.StartDate)
|
||||
}
|
||||
if filter.EndDate != "" {
|
||||
q.Set("end_date", filter.EndDate)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s?%s", basePath, q.Encode())
|
||||
}
|
||||
|
||||
// Helper function to get sort icon class
|
||||
func sortIconClass(currentSortBy, currentSortDir, targetSortBy string) string {
|
||||
if currentSortBy == targetSortBy {
|
||||
if currentSortDir == "asc" {
|
||||
return "fas fa-sort-up ml-1"
|
||||
}
|
||||
return "fas fa-sort-down ml-1"
|
||||
}
|
||||
return "fas fa-sort text-gray-400 ml-1"
|
||||
}
|
||||
|
||||
// FileMetadataListPartial renders the list of file metadata in a table format
|
||||
// Added basePath and targetContainerID parameters
|
||||
templ FileMetadataListPartial(ctx context.Context, data file_metadata.FileMetadataListData, basePath string, targetContainerID string) {
|
||||
|
||||
<!-- Container for dynamically generated dialogs -->
|
||||
<div id="dialog-container">
|
||||
for _, file := range data.Files {
|
||||
@dialog.FileMetadataDialog(
|
||||
fmt.Sprintf("delete-file-dialog-%d", file.ID),
|
||||
"Delete File Metadata",
|
||||
fmt.Sprintf("Are you sure you want to delete the metadata for '%s'? This cannot be undone.", file.FileName),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||
"Delete",
|
||||
"delete",
|
||||
file.ID,
|
||||
file.FileName,
|
||||
"list",
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- File List Table -->
|
||||
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "id", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
ID <i class={ sortIconClass(data.SortBy, data.SortDir, "id") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "filename", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Filename <i class={ sortIconClass(data.SortBy, data.SortDir, "filename") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "size", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Size <i class={ sortIconClass(data.SortBy, data.SortDir, "size") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "processed_time", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Processed time <i class={ sortIconClass(data.SortBy, data.SortDir, "processed_time") }></i>
|
||||
</a>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<a
|
||||
href="#"
|
||||
hx-get={ sortLink(data.SortBy, data.SortDir, "status", basePath, data.Filter, data.Limit) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
Status <i class={ sortIconClass(data.SortBy, data.SortDir, "status") }></i>
|
||||
</a>
|
||||
</th>
|
||||
if data.Job == nil {
|
||||
<th scope="col" class="px-6 py-3">Job</th>
|
||||
}
|
||||
<th scope="col" class="px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, file := range data.Files {
|
||||
<tr id={ fmt.Sprintf("file-row-%d", file.ID) } class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="px-6 py-4 font-medium text-gray-900 dark:text-white">
|
||||
{ strconv.FormatUint(uint64(file.ID), 10) }
|
||||
</td>
|
||||
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="hover:underline">
|
||||
{ file.FileName }
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{ utils.FormatFileSize(file.FileSize) }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{ file.ProcessedTime.Format("2006-01-02 15:04:05") }
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={ "text-xs font-medium px-2.5 py-0.5 rounded", utils.GetStatusBadgeClass(file.Status) }>
|
||||
{ file.Status }
|
||||
</span>
|
||||
</td>
|
||||
if data.Job == nil {
|
||||
<td class="px-6 py-4">
|
||||
if file.Job.ID > 0 {
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/job/%d", file.Job.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
{ file.Job.Name }
|
||||
</a>
|
||||
} else {
|
||||
<span class="text-gray-400 dark:text-gray-500 italic">N/A</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex space-x-3">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/files/%d", file.ID)) } class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-file-dialog-%d')", file.ID)} }
|
||||
data-file-id={ strconv.FormatUint(uint64(file.ID), 10) }
|
||||
data-file-name={ file.FileName }
|
||||
class="font-medium text-red-600 dark:text-red-500 hover:underline">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination with HTMX (Update links to include sorting and targetContainerID) -->
|
||||
if data.TotalPages > 1 {
|
||||
<nav class="flex items-center flex-column flex-wrap md:flex-row justify-between p-4" aria-label="Table navigation">
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{ strconv.Itoa((data.Page-1)*data.Limit+1) }-{ strconv.Itoa(func() int {
|
||||
end := data.Page*data.Limit
|
||||
if int64(end) > data.TotalCount {
|
||||
return int(data.TotalCount)
|
||||
}
|
||||
return end
|
||||
}()) }</span> of <span class="font-semibold text-gray-900 dark:text-white">{ strconv.FormatInt(data.TotalCount, 10) }</span>
|
||||
</span>
|
||||
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
|
||||
<li>
|
||||
if data.Page == 1 {
|
||||
<span class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||
Previous
|
||||
</span>
|
||||
} else {
|
||||
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page - 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
Previous
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
|
||||
for i := 1; i <= data.TotalPages; i++ {
|
||||
if i == 1 || i == data.TotalPages || (i >= data.Page-2 && i <= data.Page+2) {
|
||||
<li>
|
||||
if i == data.Page {
|
||||
<span aria-current="page" class="flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
|
||||
{ strconv.Itoa(i) }
|
||||
</span>
|
||||
} else {
|
||||
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, i, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
{ strconv.Itoa(i) }
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
} else if (i == 2 && data.Page > 4) || (i == data.TotalPages-1 && data.Page < data.TotalPages-3) {
|
||||
<li>
|
||||
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||
...
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
<li>
|
||||
if data.Page == data.TotalPages {
|
||||
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg cursor-not-allowed dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">
|
||||
Next
|
||||
</span>
|
||||
} else {
|
||||
<a hx-get={ fmt.Sprintf("%s?page=%d&limit=%d&status=%s&filename=%s&job_id=%s&sort_by=%s&sort_dir=%s", basePath, data.Page + 1, data.Limit, data.Filter.Status, data.Filter.FileName, data.Filter.JobID, data.SortBy, data.SortDir) }
|
||||
hx-target={ targetContainerID }
|
||||
hx-swap="innerHTML"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
Next
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/utils"
|
||||
"github.com/starfleetcptn/gomft/components" // Import the main components package
|
||||
)
|
||||
|
||||
// FileMetadataSearch renders the search interface for file metadata
|
||||
templ FileMetadataSearch(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||
@components.LayoutWithContext("Search Files", ctx) {
|
||||
<!-- Status and Error Messages -->
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
|
||||
@utils.FileMetadataJS() // Include JS for toasts, etc.
|
||||
|
||||
<div id="search-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-search w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Search Files
|
||||
</h1>
|
||||
<div class="flex gap-3">
|
||||
<a href="/files" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Files
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Search Form -->
|
||||
<div class="p-4 mb-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full">
|
||||
<h5 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">Advanced File Search</h5>
|
||||
<form
|
||||
hx-get="/files/search/partial"
|
||||
hx-target="#search-results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#search-form-loading"
|
||||
hx-headers='{"X-HX-Request": "true"}'
|
||||
hx-boost="false"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="job_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Job ID</label>
|
||||
<input type="text" id="job_id" name="job_id" value={ data.Filter.JobID } placeholder="Job ID"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Status</label>
|
||||
<select id="status" name="status" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="processed" selected?={ data.Filter.Status == "processed" }>Processed</option>
|
||||
<option value="archived" selected?={ data.Filter.Status == "archived" }>Archived</option>
|
||||
<option value="deleted" selected?={ data.Filter.Status == "deleted" }>Deleted</option>
|
||||
<option value="archived_and_deleted" selected?={ data.Filter.Status == "archived_and_deleted" }>Archived & Deleted</option>
|
||||
<option value="error" selected?={ data.Filter.Status == "error" }>Error</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filename" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Filename</label>
|
||||
<input type="text" id="filename" name="filename" value={ data.Filter.FileName } placeholder="Filename or partial match"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="hash" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Hash</label>
|
||||
<input type="text" id="hash" name="hash" value={ data.Filter.Hash } placeholder="MD5 hash"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="start_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed After</label>
|
||||
<input type="date" id="start_date" name="start_date" value={ data.Filter.StartDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="end_date" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Processed Before</label>
|
||||
<input type="date" id="end_date" name="end_date" value={ data.Filter.EndDate }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-search mr-2"></i> Search Files
|
||||
</button>
|
||||
<div id="search-form-loading" class="htmx-indicator ml-2 flex items-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results Container (Initially empty, populated by HTMX) -->
|
||||
<div id="search-results-container" class="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-full mt-6">
|
||||
<div class="p-4 md:p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<h5 class="text-xl font-bold leading-none text-gray-900 dark:text-white">
|
||||
Search Results
|
||||
</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Enter search criteria above and click "Search Files".
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 md:p-5">
|
||||
<!-- Content will be loaded here by HTMX -->
|
||||
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
|
||||
No results yet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('search-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('search-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata"
|
||||
"github.com/starfleetcptn/gomft/components/file_metadata/list"
|
||||
)
|
||||
|
||||
// FileMetadataSearchContent renders only the search results table and pagination
|
||||
templ FileMetadataSearchContent(ctx context.Context, data file_metadata.FileMetadataSearchData) {
|
||||
<!-- Search Results -->
|
||||
<div id="search-results">
|
||||
if len(data.Files) > 0 {
|
||||
@list.FileMetadataListPartial(ctx, file_metadata.FileMetadataListData{
|
||||
Files: data.Files,
|
||||
Page: data.Page,
|
||||
Limit: data.Limit,
|
||||
TotalCount: data.TotalCount,
|
||||
TotalPages: data.TotalPages,
|
||||
Filter: data.Filter, // Pass filter data for pagination links
|
||||
SortBy: data.SortBy,
|
||||
SortDir: data.SortDir,
|
||||
}, "/files/search/partial", "#search-results-container") // Pass correct base path and target ID
|
||||
} else {
|
||||
<div class="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto mb-4 w-12 h-12 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<p>No files found matching your search criteria.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package file_metadata
|
||||
|
||||
import "github.com/starfleetcptn/gomft/internal/db"
|
||||
|
||||
// FileMetadataFilter represents filter parameters for file metadata queries
|
||||
type FileMetadataFilter struct {
|
||||
Status string
|
||||
JobID string
|
||||
FileName string
|
||||
Hash string
|
||||
StartDate string
|
||||
EndDate string
|
||||
}
|
||||
|
||||
// FileMetadataListData contains data for the file metadata list template
|
||||
type FileMetadataListData struct {
|
||||
Files []db.FileMetadata
|
||||
TotalCount int64
|
||||
Page int
|
||||
Limit int
|
||||
TotalPages int
|
||||
Job *db.Job // Optional: if viewing files for a specific job
|
||||
Filter FileMetadataFilter
|
||||
SortBy string // Added for sorting
|
||||
SortDir string // Added for sorting ("asc" or "desc")
|
||||
}
|
||||
|
||||
// FileMetadataDetailsData contains data for the file metadata details template
|
||||
type FileMetadataDetailsData struct {
|
||||
File db.FileMetadata
|
||||
}
|
||||
|
||||
// FileMetadataSearchData contains data for the file metadata search template
|
||||
type FileMetadataSearchData struct {
|
||||
Files []db.FileMetadata
|
||||
TotalCount int64
|
||||
Page int
|
||||
Limit int
|
||||
TotalPages int
|
||||
Filter FileMetadataFilter
|
||||
SortBy string // Added for sorting
|
||||
SortDir string // Added for sorting ("asc" or "desc")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package utils
|
||||
|
||||
// FileMetadataJS provides common JavaScript functions for file metadata components,
|
||||
// including HTMX event listeners for delete toasts.
|
||||
templ FileMetadataJS() {
|
||||
<script>
|
||||
// Function to create and show a toast (Defined locally for guaranteed availability)
|
||||
function showToast(message, type = 'info') {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
if (!toastContainer) {
|
||||
console.error("Toast container not found!"); // Keep this error log
|
||||
return;
|
||||
}
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
// Use classes similar to the original file_metadata.templ for consistency
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass;
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
} else { // Default to info
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Set inner HTML with appropriate icon and message
|
||||
toast.innerHTML = `
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}">
|
||||
${type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>'}
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal">${message}</div>
|
||||
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#${toast.id}" aria-label="Close">
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to close button
|
||||
const closeButton = toast.querySelector('button[data-dismiss-target]');
|
||||
closeButton.addEventListener('click', function() {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => { toast.remove(); }, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => { toast.remove(); }, 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// --- HTMX Event Listener for Delete Toasts ---
|
||||
|
||||
// Ensure listener is attached only once using a flag
|
||||
if (!window._gomft_fileMetadataListenerAttached) {
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
const triggerElement = event.detail.elt;
|
||||
|
||||
// Check if the element that triggered this request was the file delete button from the dialog
|
||||
if (triggerElement && triggerElement.id && triggerElement.id.startsWith('delete-file-btn-')) {
|
||||
// Get the path directly from the element's hx-delete attribute
|
||||
const path = triggerElement.getAttribute('hx-delete');
|
||||
// Ensure requestConfig exists before accessing verb (robustness)
|
||||
const method = event.detail.requestConfig ? event.detail.requestConfig.verb : null;
|
||||
|
||||
// Check if the method was delete and the path from the attribute matches the expected pattern
|
||||
if (method === 'delete' && path && path.match(/^\/files\/\d+$/)) {
|
||||
const fileName = triggerElement.getAttribute('data-file-name') || "Unknown"; // Get filename from the button
|
||||
|
||||
// Call the locally defined showToast function
|
||||
if (event.detail.successful) {
|
||||
showToast(`File "${fileName}" metadata deleted successfully`, 'success');
|
||||
} else {
|
||||
let errorMsg = `Failed to delete file "${fileName}" metadata`;
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
try {
|
||||
const responseJson = JSON.parse(event.detail.xhr.responseText);
|
||||
errorMsg = responseJson.error ? `Error: ${responseJson.error}` : `Error: ${event.detail.xhr.responseText}`;
|
||||
} catch (e) {
|
||||
errorMsg = `Error: ${event.detail.xhr.responseText}`;
|
||||
}
|
||||
}
|
||||
showToast(errorMsg, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set the flag to true after attaching the listener
|
||||
window._gomft_fileMetadataListenerAttached = true;
|
||||
console.log("[FileMetadataJS] HTMX afterRequest listener attached."); // Log attachment once
|
||||
}
|
||||
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GetStatusBadgeClass returns the appropriate CSS class for a file status badge
|
||||
func GetStatusBadgeClass(status string) string {
|
||||
switch status {
|
||||
case "processed":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
|
||||
case "archived":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
|
||||
case "deleted":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
|
||||
case "archived_and_deleted":
|
||||
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"
|
||||
case "error":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatFileSize formats a file size in bytes to a human-readable string
|
||||
func FormatFileSize(size int64) string {
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
} else if size < 1024*1024 {
|
||||
return fmt.Sprintf("%.2f KB", float64(size)/1024)
|
||||
} else if size < 1024*1024*1024 {
|
||||
return fmt.Sprintf("%.2f MB", float64(size)/(1024*1024))
|
||||
} else {
|
||||
return fmt.Sprintf("%.2f GB", float64(size)/(1024*1024*1024))
|
||||
}
|
||||
}
|
||||
+183
-203
@@ -6,102 +6,91 @@ import (
|
||||
|
||||
templ ForgotPassword(ctx context.Context, errorMessage string, successMessage string) {
|
||||
@LayoutWithContext("Forgot Password", ctx) {
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="card overflow-hidden shadow-lg">
|
||||
<div class="p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Password Reset</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Enter your email to receive a reset link</p>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||
<i class="fas fa-key text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
|
||||
if errorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ errorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if successMessage != "" {
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ successMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/forgot-password"
|
||||
x-data="{
|
||||
email: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email && this.email.includes('@');
|
||||
}
|
||||
}">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
x-model="email"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="you@example.com"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
We'll send a password reset link to this email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center py-3"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Send Reset Link
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Password Reset</h2>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Enter your email to receive a reset link</p>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</p>
|
||||
if errorMessage != "" {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if successMessage != "" {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-green-50 p-4 text-green-800 dark:bg-gray-800 dark:text-green-400" role="alert">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ successMessage }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/forgot-password"
|
||||
x-data="{
|
||||
email: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email && this.email.includes('@');
|
||||
}
|
||||
}">
|
||||
<div>
|
||||
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-envelope text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
x-model="email"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="you@example.com"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
We'll send a password reset link to this email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Send Reset Link
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>Secure, encrypted connection</span>
|
||||
</div>
|
||||
@@ -114,125 +103,116 @@ templ ForgotPassword(ctx context.Context, errorMessage string, successMessage st
|
||||
// Reset password page for when users click the link from their email
|
||||
templ ResetPassword(ctx context.Context, token string, errorMessage string) {
|
||||
@LayoutWithContext("Reset Password", ctx) {
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div class="max-w-md w-full">
|
||||
<div class="card overflow-hidden shadow-lg">
|
||||
<div class="p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">Reset Password</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Create a new password for your account</p>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900">
|
||||
<i class="fas fa-lock-open text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
|
||||
if errorMessage != "" {
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ errorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/reset-password"
|
||||
x-data="{
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.password &&
|
||||
this.password.length >= 8 &&
|
||||
this.password === this.confirmPassword;
|
||||
}
|
||||
}">
|
||||
<input type="hidden" name="token" value={token}/>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">New Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Minimum 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm New Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
x-model="confirmPassword"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm"
|
||||
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-secondary-500 dark:text-secondary-400': !confirmPassword || password === confirmPassword}">
|
||||
<span x-show="!confirmPassword || password === confirmPassword">
|
||||
Passwords must match
|
||||
</span>
|
||||
<span x-show="confirmPassword && password !== confirmPassword">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Passwords do not match
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center py-3"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Reset Password
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h2>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-300">Create a new password for your account</p>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<a href="/login" class="text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</p>
|
||||
if errorMessage != "" {
|
||||
<div class="mb-6 flex items-center rounded-lg bg-red-50 p-4 text-red-800 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="[&:not(:first-child)]:ms-2">{ errorMessage }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
method="POST"
|
||||
action="/reset-password"
|
||||
x-data="{
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.password &&
|
||||
this.password.length >= 8 &&
|
||||
this.password === this.confirmPassword;
|
||||
}
|
||||
}">
|
||||
<input type="hidden" name="token" value={token}/>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">New Password</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-key text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Minimum 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-lock text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
name="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
x-model="confirmPassword"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 pl-10 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500"
|
||||
placeholder="••••••••"/>
|
||||
</div>
|
||||
<p class="mt-1 text-sm"
|
||||
x-bind:class="{'text-red-500': confirmPassword && password !== confirmPassword, 'text-gray-500 dark:text-gray-400': !confirmPassword || password === confirmPassword}">
|
||||
<span x-show="!confirmPassword || password === confirmPassword">
|
||||
Passwords must match
|
||||
</span>
|
||||
<span x-show="confirmPassword && password !== confirmPassword">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Passwords do not match
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-primary-600 px-5 py-3 text-center text-sm font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
x-bind:disabled="!validate()"
|
||||
@click="loading = true">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
Reset Password
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="me-3 h-5 w-5 animate-spin text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t border-gray-200 px-8 py-4 text-center dark:border-gray-700">
|
||||
<a href="/login" class="inline-flex items-center text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>Secure, encrypted connection</span>
|
||||
</div>
|
||||
|
||||
+194
-123
@@ -13,6 +13,7 @@ type HistoryData struct {
|
||||
SearchTerm string
|
||||
PageSize int
|
||||
Total int
|
||||
Configs map[uint]db.TransferConfig // Map of config IDs to configs for quick lookup
|
||||
}
|
||||
|
||||
// min returns the smaller of x or y
|
||||
@@ -23,69 +24,113 @@ func min(x, y int) int {
|
||||
return y
|
||||
}
|
||||
|
||||
// getConfigNameForHistory returns the appropriate name for the config used in a job history entry
|
||||
func getConfigNameForHistory(history db.JobHistory, configs map[uint]db.TransferConfig) string {
|
||||
// If ConfigID is set in the history record, use that to get the config name
|
||||
if history.ConfigID > 0 {
|
||||
if config, exists := configs[history.ConfigID]; exists {
|
||||
return config.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the Job's default Config if it exists
|
||||
if history.Job.Config.ID > 0 {
|
||||
return history.Job.Config.Name
|
||||
}
|
||||
|
||||
// If we can't determine the config name, show a default with the job name
|
||||
if history.Job.Name != "" {
|
||||
return fmt.Sprintf("%s (unknown config)", history.Job.Name)
|
||||
}
|
||||
|
||||
return "Unknown Configuration"
|
||||
}
|
||||
|
||||
// HistoryContent renders only the content part of the history page for HTMX requests
|
||||
templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
if len(data.History) == 0 {
|
||||
<div class="text-center py-12 bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-inbox text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
<div class="p-8 text-center bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div class="flex justify-center mb-4">
|
||||
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<i class="fas fa-inbox text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No transfer history</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">No transfer history</h3>
|
||||
if data.SearchTerm != "" {
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No results found for "{ data.SearchTerm }". Try a different search term or <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">clear search</button>.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
No results found for "{ data.SearchTerm }". Try a different search term or
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
clear search
|
||||
</button>.
|
||||
</p>
|
||||
} else if data.CurrentPage > 1 {
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">No more results on this page. <button hx-get="/history" hx-target="#history-content" hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) } class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">Return to first page</button>.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
No more results on this page.
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": 1, "pageSize": %d}`, data.PageSize) }
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Return to first page
|
||||
</button>.
|
||||
</p>
|
||||
} else {
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Transfer history will appear here once jobs have run.</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Transfer history will appear here once jobs have run.</p>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg">
|
||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, history := range data.History {
|
||||
<li class="hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||
<li class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">{ history.Job.Config.Name }</p>
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">{ getConfigNameForHistory(history, data.Configs) }</p>
|
||||
if history.Status == "completed" {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
<i class="fas fa-check mr-1"></i> Completed
|
||||
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
Completed
|
||||
</span>
|
||||
} else if history.Status == "failed" {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||
<i class="fas fa-times mr-1"></i> Failed
|
||||
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||
Failed
|
||||
</span>
|
||||
} else {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||
<i class="fas fa-sync-alt mr-1"></i> { history.Status }
|
||||
<span class="ml-2 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||
{ history.Status }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/job-runs/%d", history.ID)) }
|
||||
class="inline-flex items-center px-3 py-1.5 border border-secondary-300 dark:border-secondary-600 text-sm leading-5 font-medium rounded-full text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i>
|
||||
class="text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 font-medium rounded-lg text-sm px-3 py-1.5 inline-flex items-center border border-gray-300 dark:border-gray-600 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 transition-colors">
|
||||
<i class="fas fa-eye mr-1.5"></i>
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-calendar-alt mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||
Started: { history.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</p>
|
||||
if history.EndTime != nil {
|
||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-clock mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||
Duration: { history.EndTime.Sub(history.StartTime).String() }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
|
||||
if history.BytesTransferred > 0 {
|
||||
<i class="fas fa-upload flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<i class="fas fa-upload mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-500"></i>
|
||||
<p>
|
||||
{ formatBytes(history.BytesTransferred) } transferred
|
||||
</p>
|
||||
@@ -93,9 +138,9 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
</div>
|
||||
</div>
|
||||
if history.ErrorMessage != "" {
|
||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
|
||||
<div class="mt-2 p-3 rounded-lg border bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
<i class="fas fa-exclamation-triangle mr-1.5"></i>
|
||||
Error: { history.ErrorMessage }
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,22 +153,26 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
|
||||
<!-- Pagination -->
|
||||
if data.TotalPages > 1 {
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<div class="mt-6 flex flex-col sm:flex-row justify-between items-center py-4 bg-white dark:bg-gray-800 rounded-lg shadow px-4">
|
||||
<!-- Mobile pagination -->
|
||||
<div class="flex-1 flex justify-between gap-4 sm:hidden w-full mb-4 sm:mb-0">
|
||||
if data.CurrentPage > 1 {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#mobile-prev-indicator"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
<span id="mobile-prev-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
</span>
|
||||
<span id="mobile-prev-indicator" class="htmx-indicator absolute right-2">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
||||
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left mr-1"></i> Previous
|
||||
</span>
|
||||
}
|
||||
@@ -134,21 +183,26 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#mobile-next-indicator"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
<span id="mobile-next-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
</span>
|
||||
<span id="mobile-next-indicator" class="htmx-indicator absolute right-2">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-700 text-sm font-medium rounded-md text-secondary-300 dark:text-secondary-600 bg-secondary-100 dark:bg-secondary-800 cursor-not-allowed">
|
||||
<span class="w-full inline-flex justify-center items-center px-4 py-2 text-sm font-medium text-gray-400 bg-gray-100 border border-gray-300 rounded-lg dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
Next <i class="fas fa-chevron-right ml-1"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Desktop pagination -->
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing
|
||||
<span class="font-medium">{ fmt.Sprint((data.CurrentPage-1)*data.PageSize + 1) }</span>
|
||||
to
|
||||
@@ -159,24 +213,23 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
if data.CurrentPage > 1 {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage-1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#prev-indicator"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
||||
<span id="prev-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
<i class="fas fa-chevron-left text-xs"></i>
|
||||
<span id="prev-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left h-5 w-5"></i>
|
||||
<span class="relative inline-flex items-center justify-center rounded-l-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-left text-xs"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -189,17 +242,16 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, data.CurrentPage+1, data.PageSize, data.SearchTerm) }
|
||||
hx-indicator="#next-indicator"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
||||
<span id="next-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
|
||||
>
|
||||
<i class="fas fa-chevron-right text-xs"></i>
|
||||
<span id="next-indicator" class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else {
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-secondary-300 dark:border-secondary-700 bg-secondary-100 dark:bg-secondary-800 text-sm font-medium text-secondary-300 dark:text-secondary-600 cursor-not-allowed">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right h-5 w-5"></i>
|
||||
<span class="relative inline-flex items-center justify-center rounded-r-lg border border-gray-300 bg-gray-100 px-3 py-2 text-sm font-medium text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-600 cursor-not-allowed">
|
||||
<i class="fas fa-chevron-right text-xs"></i>
|
||||
</span>
|
||||
}
|
||||
</nav>
|
||||
@@ -212,42 +264,45 @@ templ HistoryContent(ctx context.Context, data HistoryData) {
|
||||
|
||||
templ History(ctx context.Context, data HistoryData) {
|
||||
@LayoutWithContext("Transfer History", ctx) {
|
||||
<div id="history-page" class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-history mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<div id="history-container" style="min-height: 100vh;" class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-history w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Transfer History
|
||||
</h1>
|
||||
<div class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg px-4 py-2 inline-flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="font-medium">Total: { fmt.Sprint(data.Total) } transfers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Pagination Controls -->
|
||||
<div class="mt-4 flex flex-col sm:flex-row justify-between items-center bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg mb-6">
|
||||
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="w-full sm:w-auto mb-4 sm:mb-0">
|
||||
<div class="flex">
|
||||
<div class="relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-secondary-400 dark:text-secondary-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={ data.SearchTerm }
|
||||
placeholder="Search by job name or status..."
|
||||
class="block w-full pl-10 pr-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md leading-5 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 placeholder-secondary-500 dark:placeholder-secondary-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
/>
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||
<div class="mb-6 p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<form hx-get="/history" hx-target="#history-content" hx-indicator="#search-indicator" class="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div class="w-full sm:w-auto relative flex-grow">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={ data.SearchTerm }
|
||||
placeholder="Search by job name or status..."
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 pr-3 py-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
/>
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="hidden" name="pageSize" value={ fmt.Sprint(data.PageSize) } />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-primary-500 dark:hover:bg-primary-600 transition-colors"
|
||||
class="inline-flex items-center py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg border border-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
>
|
||||
<span>Search</span>
|
||||
<span id="search-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-search mr-2 -ml-1"></i>
|
||||
Search
|
||||
<span id="search-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
@@ -257,77 +312,93 @@ templ History(ctx context.Context, data HistoryData) {
|
||||
hx-target="#history-content"
|
||||
hx-indicator="#clear-indicator"
|
||||
hx-vals={ fmt.Sprintf(`{"pageSize": %d}`, data.PageSize) }
|
||||
class="ml-2 inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
|
||||
>
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
<span id="clear-indicator" class="htmx-indicator ml-1">
|
||||
<span id="clear-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<label class="inline-flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
Show
|
||||
<select
|
||||
name="pageSize"
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-include="[name='search']"
|
||||
hx-indicator="#size-indicator"
|
||||
class="mx-2 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
>
|
||||
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||
</select>
|
||||
entries
|
||||
<span id="size-indicator" class="htmx-indicator ml-2">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
Show
|
||||
<select
|
||||
name="pageSize"
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-include="[name='search']"
|
||||
hx-indicator="#size-indicator"
|
||||
class="mx-1 rounded-md border-secondary-300 dark:border-secondary-600 py-1 text-base bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:border-primary-500 focus:outline-none focus:ring-primary-500 sm:text-sm"
|
||||
>
|
||||
<option value="10" selected?={ data.PageSize == 10 }>10</option>
|
||||
<option value="25" selected?={ data.PageSize == 25 }>25</option>
|
||||
<option value="50" selected?={ data.PageSize == 50 }>50</option>
|
||||
<option value="100" selected?={ data.PageSize == 100 }>100</option>
|
||||
</select>
|
||||
entries
|
||||
<span id="size-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="history-content" class="mt-6">
|
||||
@HistoryContent(ctx, data)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('history-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('history-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ pageNumbers(currentPage int, totalPages int, pageSize int, searchTerm string) {
|
||||
// Show at most 5 page numbers with the current page in the middle when possible
|
||||
{{startPage := max(1, currentPage-2)}}
|
||||
{{endPage := min(totalPages, startPage+4)}}
|
||||
|
||||
// Adjust startPage if we're near the end
|
||||
if endPage - startPage < 4 && startPage > 1 {
|
||||
startPage = max(1, endPage-4)
|
||||
}
|
||||
|
||||
for i := startPage; i <= endPage; i++ {
|
||||
for i := 1; i <= totalPages; i++ {
|
||||
if i == currentPage {
|
||||
<span aria-current="page" class="relative inline-flex items-center px-4 py-2 border border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
<span aria-current="page" class="relative z-10 inline-flex items-center bg-blue-600 px-4 py-2 text-sm font-medium text-white focus:z-20 border border-blue-600">
|
||||
{ fmt.Sprint(i) }
|
||||
</span>
|
||||
} else {
|
||||
} else if i == 1 || i == totalPages || (i >= currentPage-2 && i <= currentPage+2) {
|
||||
<button
|
||||
hx-get="/history"
|
||||
hx-target="#history-content"
|
||||
hx-vals={ fmt.Sprintf(`{"page": %d, "pageSize": %d, "search": "%s"}`, i, pageSize, searchTerm) }
|
||||
hx-indicator="#page-indicator"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors">
|
||||
hx-indicator={ fmt.Sprintf("#page-indicator-%d", i) }
|
||||
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 focus:z-20 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 relative"
|
||||
>
|
||||
{ fmt.Sprint(i) }
|
||||
<span id="page-indicator" class="htmx-indicator ml-1">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span id={ fmt.Sprintf("page-indicator-%d", i) } class="htmx-indicator absolute top-0 right-0 w-4 h-4 -mt-1 -mr-1">
|
||||
<i class="fas fa-spinner fa-spin text-xs"></i>
|
||||
</span>
|
||||
</button>
|
||||
} else if i == currentPage-3 || i == currentPage+3 {
|
||||
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||
...
|
||||
</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+124
-99
@@ -6,130 +6,155 @@ import (
|
||||
|
||||
templ Home(ctx context.Context) {
|
||||
@LayoutWithContext("Home", ctx) {
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-secondary-900 dark:text-secondary-100 mb-4">
|
||||
<i class="fas fa-exchange-alt text-primary-600 dark:text-primary-400 mr-3"></i>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center justify-center">
|
||||
<i class="fas fa-exchange-alt w-8 h-8 mr-3 text-blue-500"></i>
|
||||
Welcome to GoMFT
|
||||
</h1>
|
||||
<p class="text-xl text-secondary-600 dark:text-secondary-300 mb-12">A modern managed file transfer solution</p>
|
||||
|
||||
if isLoggedIn(ctx) {
|
||||
<div class="space-y-8">
|
||||
<!-- Feature Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6">
|
||||
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||
<i class="fas fa-cogs text-primary-600 dark:text-primary-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Transfer Configs</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Set up and manage file transfer configurations with ease.</p>
|
||||
<div class="flex justify-center">
|
||||
<a href="/configs" class="btn-primary inline-flex items-center">
|
||||
<span>View Configs</span>
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6">
|
||||
<div class="rounded-full bg-blue-100 dark:bg-blue-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Schedule Jobs</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">Create and monitor automated file transfer jobs.</p>
|
||||
<div class="flex justify-center">
|
||||
<a href="/jobs" class="btn-primary inline-flex items-center">
|
||||
<span>View Jobs</span>
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6">
|
||||
<div class="rounded-full bg-green-100 dark:bg-green-900 p-4 w-16 h-16 flex items-center justify-center mb-4 mx-auto">
|
||||
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4 text-center">Track History</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6 text-center">View detailed history of all file transfer operations.</p>
|
||||
<div class="flex justify-center">
|
||||
<a href="/history" class="btn-primary inline-flex items-center">
|
||||
<span>View History</span>
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-lg text-gray-500 dark:text-gray-400 mt-2">
|
||||
A modern managed file transfer solution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
if isLoggedIn(ctx) {
|
||||
<!-- Logged-in User View -->
|
||||
<div class="space-y-8">
|
||||
<!-- Feature Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Transfer Configs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mb-4 dark:bg-blue-900">
|
||||
<i class="fas fa-cogs text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Transfer Configs</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-5">Set up and manage file transfer configurations with ease.</p>
|
||||
<a href="/configs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
View Configs
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access Section -->
|
||||
<div class="mt-12">
|
||||
<h2 class="text-2xl font-bold text-secondary-900 dark:text-secondary-100 mb-6">
|
||||
<i class="fas fa-bolt text-primary-600 dark:text-primary-400 mr-2"></i>
|
||||
Quick Access
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/dashboard" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-tachometer-alt text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">Dashboard</span>
|
||||
<!-- Schedule Jobs Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 mb-4 dark:bg-indigo-900">
|
||||
<i class="fas fa-calendar-alt text-indigo-600 dark:text-indigo-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Schedule Jobs</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-5">Create and monitor automated file transfer jobs.</p>
|
||||
<a href="/jobs" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
View Jobs
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
<a href="/configs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-plus-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">New Config</span>
|
||||
</a>
|
||||
<a href="/jobs/new" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-play-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">New Job</span>
|
||||
</a>
|
||||
<a href="/profile" class="card p-4 hover:bg-secondary-50 dark:hover:bg-secondary-800 flex items-center transition-colors">
|
||||
<i class="fas fa-user-circle text-primary-600 dark:text-primary-400 text-xl mr-3"></i>
|
||||
<span class="text-secondary-900 dark:text-secondary-100">Profile</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track History Card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-green-100 mb-4 dark:bg-green-900">
|
||||
<i class="fas fa-history text-green-600 dark:text-green-300 text-2xl"></i>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Track History</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-5">View detailed history of all file transfer operations.</p>
|
||||
<a href="/history" class="inline-flex items-center px-4 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
View History
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="max-w-md mx-auto card p-8 hover:shadow-lg transition-shadow duration-300">
|
||||
<div class="rounded-full bg-primary-100 dark:bg-primary-900 p-4 w-20 h-20 flex items-center justify-center mb-6 mx-auto">
|
||||
<i class="fas fa-sign-in-alt text-primary-600 dark:text-primary-300 text-3xl"></i>
|
||||
|
||||
<!-- Quick Access Section -->
|
||||
<div class="mt-10">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-bolt text-blue-500 w-6 h-6 mr-2"></i>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Quick Access</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/dashboard" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-tachometer-alt text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Dashboard</span>
|
||||
</a>
|
||||
<a href="/configs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-plus-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">New Config</span>
|
||||
</a>
|
||||
<a href="/jobs/new" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-play-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">New Job</span>
|
||||
</a>
|
||||
<a href="/profile" class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-4 flex items-center transition-colors hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 mr-3 dark:bg-blue-900">
|
||||
<i class="fas fa-user-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Profile</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Login Card for Non-Logged-In Users -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-6 text-center">
|
||||
<div class="inline-flex items-center justify-center w-32 h-32 mb-6">
|
||||
<img src="/static/img/logo.svg" alt="GoMFT Logo" class="w-full h-full"/>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Get Started</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-6">Log in to access the file transfer management system.</p>
|
||||
<a href="/login" class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-lock mr-2"></i>
|
||||
Log In
|
||||
</a>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-secondary-900 dark:text-secondary-100 mb-4">Get Started</h2>
|
||||
<p class="text-secondary-600 dark:text-secondary-400 mb-6">Log in to access the file transfer management system.</p>
|
||||
<a href="/login" class="btn-primary inline-flex items-center px-8 py-3">
|
||||
<i class="fas fa-lock mr-2"></i>
|
||||
<span>Log In</span>
|
||||
</a>
|
||||
|
||||
<!-- Features Preview -->
|
||||
<div class="mt-12 pt-8 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-4">Key Features</h3>
|
||||
<ul class="space-y-3 text-left">
|
||||
<div class="p-6 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||
<h3 class="text-base font-medium text-gray-900 dark:text-white mb-4">Key Features</h3>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">Secure file transfers with encryption</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">Secure file transfers with encryption</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">Automated scheduling and monitoring</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">Automated scheduling and monitoring</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">Comprehensive transfer history tracking</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">Comprehensive transfer history tracking</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-500 mt-1 mr-2"></i>
|
||||
<span class="text-secondary-600 dark:text-secondary-400">User-friendly interface with dark mode support</span>
|
||||
<i class="fas fa-check-circle text-green-500 mt-0.5 mr-2 flex-shrink-0"></i>
|
||||
<span class="text-gray-500 dark:text-gray-400">User-friendly interface with dark mode support</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">About GoMFT</h4>
|
||||
<p>GoMFT is a modern, secure file transfer solution that allows you to effortlessly move files between different systems and storage providers with robust tracking and monitoring.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+585
-251
@@ -26,266 +26,600 @@ func getJobTitle(isNew bool) string {
|
||||
return "Edit Job"
|
||||
}
|
||||
|
||||
// configSelected checks if a config ID is selected for a job
|
||||
func configSelected(job *db.Job, configID uint) bool {
|
||||
if job.ConfigIDs != "" {
|
||||
// If ConfigIDs is populated, only check against those IDs
|
||||
for _, id := range job.GetConfigIDsList() {
|
||||
if id == configID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
// If ConfigIDs is empty, fall back to checking the primary ConfigID
|
||||
return job.ConfigID == configID
|
||||
}
|
||||
}
|
||||
|
||||
templ configSearchScript() {
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Handle search for new job form
|
||||
const configSearch = document.getElementById('config-search');
|
||||
if (configSearch) {
|
||||
configSearch.addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const configItems = document.querySelectorAll('#config-list .config-item');
|
||||
|
||||
configItems.forEach(item => {
|
||||
const name = item.getAttribute('data-name').toLowerCase();
|
||||
item.style.display = name.includes(searchTerm) ? 'flex' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle search for edit job form
|
||||
const configSearchEdit = document.getElementById('config-search-edit');
|
||||
if (configSearchEdit) {
|
||||
configSearchEdit.addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const configItems = document.querySelectorAll('#config-list-edit .config-item');
|
||||
|
||||
configItems.forEach(item => {
|
||||
const name = item.getAttribute('data-name').toLowerCase();
|
||||
item.style.display = name.includes(searchTerm) ? 'flex' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle job ordering
|
||||
const setupJobOrdering = (configListId, selectedListId, formId, savedOrder) => {
|
||||
const configList = document.getElementById(configListId);
|
||||
const selectedList = document.getElementById(selectedListId);
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
if (!configList || !selectedList || !form) return;
|
||||
|
||||
// Get saved order if available
|
||||
const orderedIds = savedOrder ? savedOrder.split(',').map(id => id.trim()) : [];
|
||||
console.log('Initial saved order:', orderedIds);
|
||||
|
||||
// Initialize selected items from checked checkboxes
|
||||
const updateSelectedItems = (initialLoad = false) => {
|
||||
// Clear current list
|
||||
selectedList.innerHTML = '';
|
||||
|
||||
// Get all checked checkboxes
|
||||
const checkedItems = configList.querySelectorAll('input[type="checkbox"]:checked');
|
||||
|
||||
if (checkedItems.length === 0) {
|
||||
selectedList.innerHTML = '<div class="text-center py-4 text-secondary-500 dark:text-secondary-400">No configurations selected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a map of config items for easy access
|
||||
const configItems = {};
|
||||
checkedItems.forEach(checkbox => {
|
||||
configItems[checkbox.value] = {
|
||||
checkbox: checkbox,
|
||||
configId: checkbox.value,
|
||||
configName: checkbox.nextElementSibling.textContent.trim()
|
||||
};
|
||||
});
|
||||
|
||||
// If we have a saved order and this is the initial load, use that order
|
||||
let itemsToShow = [];
|
||||
if (initialLoad && orderedIds.length > 0) {
|
||||
// First add items in the saved order
|
||||
orderedIds.forEach(id => {
|
||||
if (configItems[id]) {
|
||||
itemsToShow.push(configItems[id]);
|
||||
delete configItems[id]; // Remove from map to avoid duplicates
|
||||
}
|
||||
});
|
||||
|
||||
// Then add any remaining checked items not in the saved order
|
||||
Object.values(configItems).forEach(item => {
|
||||
itemsToShow.push(item);
|
||||
});
|
||||
} else {
|
||||
// Just add all checked items in their current order
|
||||
itemsToShow = Object.values(configItems);
|
||||
}
|
||||
|
||||
// Add each item to the selected list
|
||||
itemsToShow.forEach((item, index) => {
|
||||
const configId = item.configId;
|
||||
const configName = item.configName;
|
||||
|
||||
const listItem = document.createElement('div');
|
||||
listItem.className = 'flex items-center justify-between p-2 mb-2 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-700 rounded-lg';
|
||||
listItem.setAttribute('data-id', configId);
|
||||
|
||||
listItem.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-primary-100 dark:bg-primary-900 mr-2 text-primary-700 dark:text-primary-300 text-sm">${index + 1}</span>
|
||||
<span class="font-medium text-secondary-700 dark:text-secondary-300">${configName}</span>
|
||||
</div>
|
||||
<div class="flex space-x-1">
|
||||
<button type="button" class="move-up p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move up">
|
||||
<i class="fas fa-arrow-up text-secondary-500"></i>
|
||||
</button>
|
||||
<button type="button" class="move-down p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700" title="Move down">
|
||||
<i class="fas fa-arrow-down text-secondary-500"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
selectedList.appendChild(listItem);
|
||||
});
|
||||
|
||||
// Update hidden order inputs
|
||||
updateOrderInputs();
|
||||
};
|
||||
|
||||
// Update hidden inputs with the current order
|
||||
const updateOrderInputs = () => {
|
||||
const items = selectedList.querySelectorAll('.flex.items-center.justify-between');
|
||||
if (items.length === 0) return;
|
||||
|
||||
// Remove any existing order input to avoid duplicates
|
||||
const existingOrderInput = form.querySelector('input[name="config_order"]');
|
||||
if (existingOrderInput) {
|
||||
existingOrderInput.remove();
|
||||
}
|
||||
|
||||
// Create a new input with the current order
|
||||
const orderedIds = Array.from(items).map(item => item.getAttribute('data-id'));
|
||||
|
||||
// Create a hidden input to store the order
|
||||
const configOrderInput = document.createElement('input');
|
||||
configOrderInput.type = 'hidden';
|
||||
configOrderInput.name = 'config_order';
|
||||
configOrderInput.value = orderedIds.join(',');
|
||||
|
||||
// Add the input to the form
|
||||
form.appendChild(configOrderInput);
|
||||
|
||||
// Update the visible order numbers
|
||||
items.forEach((item, index) => {
|
||||
const orderNum = index + 1;
|
||||
const orderSpan = item.querySelector('span.rounded-full');
|
||||
if (orderSpan) {
|
||||
orderSpan.textContent = orderNum;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Updated order input:', configOrderInput.value);
|
||||
};
|
||||
|
||||
// Initialize the selected list with saved order if available
|
||||
updateSelectedItems(true);
|
||||
|
||||
// Handle checkbox changes
|
||||
configList.addEventListener('change', (e) => {
|
||||
if (e.target.matches('input[type="checkbox"]')) {
|
||||
updateSelectedItems(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle reordering
|
||||
selectedList.addEventListener('click', (e) => {
|
||||
const listItem = e.target.closest('.flex.items-center.justify-between');
|
||||
if (!listItem) return;
|
||||
|
||||
if (e.target.closest('.move-up')) {
|
||||
const prev = listItem.previousElementSibling;
|
||||
if (prev) {
|
||||
selectedList.insertBefore(listItem, prev);
|
||||
updateOrderInputs();
|
||||
}
|
||||
} else if (e.target.closest('.move-down')) {
|
||||
const next = listItem.nextElementSibling;
|
||||
if (next) {
|
||||
selectedList.insertBefore(next, listItem);
|
||||
updateOrderInputs();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the order input is updated before submission
|
||||
form.addEventListener('submit', function(e) {
|
||||
updateOrderInputs();
|
||||
console.log('Form submitted with order:', form.querySelector('input[name="config_order"]')?.value);
|
||||
});
|
||||
};
|
||||
|
||||
// Setup ordering for new job form
|
||||
setupJobOrdering('config-list', 'selected-configs', 'new-job-form', null);
|
||||
|
||||
// Setup ordering for edit job form
|
||||
const editJobForm = document.getElementById('edit-job-form');
|
||||
const savedOrderEdit = editJobForm ? editJobForm.getAttribute('data-config-order') : null;
|
||||
setupJobOrdering('config-list-edit', 'selected-configs-edit', 'edit-job-form', savedOrderEdit);
|
||||
});
|
||||
</script>
|
||||
}
|
||||
|
||||
templ JobForm(ctx context.Context, data JobFormData) {
|
||||
@LayoutWithContext(getJobFormTitle(data.IsNew), ctx) {
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div class="max-w-3xl w-full">
|
||||
<div class="card overflow-hidden shadow-lg">
|
||||
<div class="p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-primary-100 dark:bg-primary-900 mb-4">
|
||||
<i class="fas fa-calendar-alt text-primary-600 dark:text-primary-400 text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
{ getJobTitle(data.IsNew) }
|
||||
</h2>
|
||||
<p class="mt-2 text-secondary-600 dark:text-secondary-400">Configure your scheduled transfer job</p>
|
||||
@configSearchScript()
|
||||
|
||||
<!-- Main Content -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 md:p-8">
|
||||
<!-- Form Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<span class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-50 dark:bg-blue-900">
|
||||
<i class="fas fa-calendar-alt text-blue-600 dark:text-blue-300 text-2xl"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
if data.IsNew {
|
||||
<form
|
||||
class="space-y-6"
|
||||
hx-post="/jobs"
|
||||
hx-target="body"
|
||||
hx-boost="true"
|
||||
@htmx:before-request="loading = true"
|
||||
@htmx:after-request="loading = false"
|
||||
@htmx:response-error="$dispatch('notification', { message: 'Failed to create job: ' + event.detail.xhr.responseText, type: 'error' })"
|
||||
x-data="{ name: '', configId: '', schedule: '', enabled: true, loading: false, validate() { return this.configId && this.schedule; } }">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
x-model="name"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Daily Production Backup"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Descriptive name for this job (optional). If not provided, the config name will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<select
|
||||
id="config_id"
|
||||
name="config_id"
|
||||
x-model="configId"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||
<option value="">Select a configuration</option>
|
||||
for _, config := range data.Configs {
|
||||
<option value={ fmt.Sprint(config.ID) }>{ config.Name }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="schedule"
|
||||
id="schedule"
|
||||
x-model="schedule"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="*/15 * * * *"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
x-model="enabled"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"
|
||||
checked/>
|
||||
<input type="hidden" name="enabled" :value="enabled.toString()"/>
|
||||
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
|
||||
Disabled jobs will not run automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-5 flex justify-end space-x-3">
|
||||
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary flex items-center justify-center px-4 py-2"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create Job
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} else {
|
||||
<form
|
||||
class="space-y-6"
|
||||
hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) }
|
||||
hx-target="body"
|
||||
hx-boost="true"
|
||||
@htmx:before-request="loading = true"
|
||||
@htmx:after-request="loading = false"
|
||||
@htmx:response-error="$dispatch('notification', { message: 'Failed to update job: ' + event.detail.xhr.responseText, type: 'error' })"
|
||||
x-data={ fmt.Sprintf("{ name: '%s', configId: '%d', schedule: '%s', enabled: %v, loading: false, validate() { return this.configId && this.schedule; } }", data.Job.Name, data.Job.ConfigID, data.Job.Schedule, data.Job.Enabled) }>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Job Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-tag text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
x-model="name"
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Daily Production Backup"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Descriptive name for this job (optional). If not provided, the config name will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="config_id" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Transfer Configuration</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-cog text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<select
|
||||
id="config_id"
|
||||
name="config_id"
|
||||
x-model="configId"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500">
|
||||
<option value="">Select a configuration</option>
|
||||
for _, config := range data.Configs {
|
||||
<option value={ fmt.Sprint(config.ID) } if data.Job != nil && data.Job.ConfigID == config.ID { selected }>{ config.Name }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="schedule" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Schedule (Cron Expression)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-clock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="schedule"
|
||||
id="schedule"
|
||||
x-model="schedule"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="*/15 * * * *"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-secondary-50 dark:bg-secondary-800 p-4 rounded-lg border border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
x-model="enabled"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded"/>
|
||||
<input type="hidden" name="enabled" :value="enabled.toString()"/>
|
||||
<label for="enabled" class="ml-2 block text-sm font-medium text-secondary-700 dark:text-secondary-300">Enable this job</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1 text-amber-500"></i>
|
||||
Disabled jobs will not run automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-5 flex justify-end space-x-3">
|
||||
<a href="/jobs" class="btn-secondary flex items-center justify-center px-4 py-2">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary flex items-center justify-center px-4 py-2"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Save Changes
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700 text-center">
|
||||
<p class="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Jobs will run according to their schedule and execute the selected transfer configuration
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{ getJobTitle(data.IsNew) }
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure your scheduled file transfer job using the form below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Job Form Information -->
|
||||
<div class="p-4 mb-6 bg-blue-50 border border-blue-100 rounded-lg dark:bg-blue-900/20 dark:border-blue-900">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
|
||||
<h3 class="text-lg font-medium text-blue-600 dark:text-blue-400">Job Information</h3>
|
||||
</div>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
Jobs run on a schedule and can include one or more transfer configurations. Select which configurations to run and in what order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Form -->
|
||||
if data.IsNew {
|
||||
<form id="new-job-form" hx-post="/jobs" hx-target="body" hx-boost="true" class="space-y-6">
|
||||
<!-- Job Details Section -->
|
||||
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-tag mr-2 text-blue-500 dark:text-blue-400"></i>Job Details
|
||||
</h3>
|
||||
|
||||
<!-- Job name field -->
|
||||
<div class="mb-6">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Job Name
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<i class="fas fa-tag text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Daily Production Backup"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Descriptive name for this job (optional). If not provided, the config name will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Schedule field -->
|
||||
<div class="mb-6">
|
||||
<label for="schedule" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Schedule (Cron Expression)
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<i class="fas fa-clock text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="schedule"
|
||||
id="schedule"
|
||||
required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="*/15 * * * *"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Enabled toggle -->
|
||||
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
value="true"
|
||||
id="enabled"
|
||||
if data.Job.GetEnabled() {
|
||||
checked
|
||||
}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Enable this job</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Disabled jobs won't run automatically but can still be triggered manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Selection Section -->
|
||||
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cog mr-2 text-blue-500 dark:text-blue-400"></i>Transfer Configurations
|
||||
</h3>
|
||||
|
||||
<!-- Search field -->
|
||||
<div class="mb-4">
|
||||
<label for="config-search" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Search Configurations
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<i class="fas fa-search text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="config-search"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Search configurations..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration list -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Available Configurations
|
||||
</label>
|
||||
<div class="border border-gray-200 rounded-lg dark:border-gray-700 overflow-hidden">
|
||||
<div class="max-h-56 overflow-y-auto" id="config-list">
|
||||
if len(data.Configs) > 0 {
|
||||
for _, config := range data.Configs {
|
||||
<div class="config-item px-4 py-3 border-b last:border-b-0 border-gray-200 dark:border-gray-700 flex items-center" data-name={ config.Name }>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="config_ids[]"
|
||||
id={ fmt.Sprintf("config_%d", config.ID) }
|
||||
value={ fmt.Sprint(config.ID) }
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<label for={ fmt.Sprintf("config_%d", config.ID) } class="ms-2 text-sm font-medium text-gray-900 dark:text-white cursor-pointer w-full">
|
||||
{ config.Name }
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
No configurations available. <a href="/configs/new" class="text-blue-600 dark:text-blue-500 hover:underline">Create one</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected configurations order list -->
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
|
||||
</label>
|
||||
<div id="selected-configs" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-16 bg-gray-50 dark:bg-gray-800">
|
||||
<!-- Items will be populated by JavaScript -->
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use the arrows to change the order in which configurations will execute.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form actions -->
|
||||
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/jobs" class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus mr-2"></i>Create Job
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} else {
|
||||
<form id="edit-job-form" hx-post={ fmt.Sprintf("/jobs/%d", data.Job.ID) } hx-target="body" hx-boost="true" data-config-order={ data.Job.ConfigIDs } class="space-y-6">
|
||||
<!-- Job Details Section -->
|
||||
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-tag mr-2 text-blue-500 dark:text-blue-400"></i>Job Details
|
||||
</h3>
|
||||
|
||||
<!-- Job name field -->
|
||||
<div class="mb-6">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Job Name
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<i class="fas fa-tag text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
value={ data.Job.Name }
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Daily Production Backup"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Descriptive name for this job (optional). If not provided, the config name will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Schedule field -->
|
||||
<div class="mb-6">
|
||||
<label for="schedule" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Schedule (Cron Expression)
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<i class="fas fa-clock text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="schedule"
|
||||
id="schedule"
|
||||
value={ data.Job.Schedule }
|
||||
required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="*/15 * * * *"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use standard cron expression format. Example: */15 * * * * (every 15 minutes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Enabled toggle -->
|
||||
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
value="true"
|
||||
id="enabled"
|
||||
if data.Job.GetEnabled() {
|
||||
checked
|
||||
}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Enable this job</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Disabled jobs won't run automatically but can still be triggered manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Selection Section -->
|
||||
<div class="p-5 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<h3 class="mb-4 text-xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cog mr-2 text-blue-500 dark:text-blue-400"></i>Transfer Configurations
|
||||
</h3>
|
||||
|
||||
<!-- Search field -->
|
||||
<div class="mb-4">
|
||||
<label for="config-search-edit" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Search Configurations
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||
<i class="fas fa-search text-gray-500 dark:text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="config-search-edit"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Search configurations..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration list -->
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Available Configurations
|
||||
</label>
|
||||
<div class="border border-gray-200 rounded-lg dark:border-gray-700 overflow-hidden">
|
||||
<div class="max-h-56 overflow-y-auto" id="config-list-edit">
|
||||
if len(data.Configs) > 0 {
|
||||
for _, config := range data.Configs {
|
||||
<div class="config-item px-4 py-3 border-b last:border-b-0 border-gray-200 dark:border-gray-700 flex items-center" data-name={ config.Name }>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="config_ids[]"
|
||||
id={ fmt.Sprintf("config_edit_%d", config.ID) }
|
||||
value={ fmt.Sprint(config.ID) }
|
||||
if configSelected(data.Job, config.ID) {
|
||||
checked
|
||||
}
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<label for={ fmt.Sprintf("config_edit_%d", config.ID) } class="ms-2 text-sm font-medium text-gray-900 dark:text-white cursor-pointer w-full">
|
||||
{ config.Name }
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
<div class="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
No configurations available. <a href="/configs/new" class="text-blue-600 dark:text-blue-500 hover:underline">Create one</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected configurations order list -->
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-sort-amount-down mr-1"></i> Execution Order
|
||||
</label>
|
||||
<div id="selected-configs-edit" class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-16 bg-gray-50 dark:bg-gray-800">
|
||||
<!-- Items will be populated by JavaScript -->
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use the arrows to change the order in which configurations will execute.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form actions -->
|
||||
<div class="flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/jobs" class="text-white bg-gray-500 hover:bg-gray-600 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i>Cancel
|
||||
</a>
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-save mr-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-question-circle mr-2 text-primary-500"></i>
|
||||
<span>Need help with cron expressions? Try <a href="https://crontab.guru/" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">crontab.guru</a></span>
|
||||
<!-- Help Card -->
|
||||
<div class="mt-4 p-4 bg-gray-50 border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-lightbulb text-yellow-400 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h5 class="text-sm font-medium text-gray-900 dark:text-white">Scheduling Tips</h5>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Need help with cron expressions? Try <a href="https://crontab.guru/" target="_blank" class="font-medium underline hover:no-underline">crontab.guru</a> for a visual editor. Jobs can run multiple configurations in sequence, useful for multi-step transfer workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
+156
-206
@@ -15,221 +15,171 @@ type JobRunDetailsData struct {
|
||||
|
||||
templ JobRunDetails(ctx context.Context, data JobRunDetailsData) {
|
||||
@LayoutWithContext("Job Run Details", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
||||
</a>
|
||||
@JobRunDetailsContent(ctx, data)
|
||||
}
|
||||
}
|
||||
|
||||
// JobRunDetailsContent is the same as JobRunDetails but without the layout wrapper
|
||||
// This is used for testing
|
||||
templ JobRunDetailsContent(ctx context.Context, data JobRunDetailsData) {
|
||||
<div class="py-6 px-4 mx-auto max-w-7xl lg:px-8">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard" class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 inline-flex items-center">
|
||||
<i class="fas fa-arrow-left mr-2"></i> Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-alt mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||
Job Run Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Job Run Information Card -->
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">{ data.Job.Name }</h3>
|
||||
if data.JobHistory.Status == "completed" {
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 inline-flex items-center">
|
||||
<i class="fas fa-check mr-2"></i> Completed
|
||||
</span>
|
||||
} else if data.JobHistory.Status == "failed" {
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300 inline-flex items-center">
|
||||
<i class="fas fa-times mr-2"></i> Failed
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 inline-flex items-center">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i> Running
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-file-alt mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
Job Run Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Job Run Information Card -->
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
||||
if data.Job.Name != "" {
|
||||
{ data.Job.Name }
|
||||
} else {
|
||||
{ data.Config.Name }
|
||||
}
|
||||
</h3>
|
||||
if data.JobHistory.Status == "completed" {
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
<i class="fas fa-check mr-1"></i> Completed
|
||||
</span>
|
||||
} else if data.JobHistory.Status == "failed" {
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
|
||||
<i class="fas fa-times mr-1"></i> Failed
|
||||
</span>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Config: { data.Config.Name }</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-calendar-alt mr-2 text-gray-400 dark:text-gray-500"></i> Start Time
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-calendar-check mr-2 text-gray-400 dark:text-gray-500"></i> End Time
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||
} else {
|
||||
<span class="px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
|
||||
<i class="fas fa-sync-alt mr-1"></i> { data.JobHistory.Status }
|
||||
</span>
|
||||
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||
}
|
||||
</div>
|
||||
if data.Job.Name != "" && data.Job.Name != data.Config.Name {
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Config: { data.Config.Name }
|
||||
</p>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-alt mr-1"></i> Start Time
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ data.JobHistory.StartTime.Format("Jan 02, 2006 15:04:05") }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-check mr-1"></i> End Time
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ data.JobHistory.EndTime.Format("Jan 02, 2006 15:04:05") }
|
||||
} else {
|
||||
<span class="text-secondary-500 dark:text-secondary-400">Still running...</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-clock mr-1"></i> Duration
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ formatDuration(data.JobHistory.EndTime.Sub(data.JobHistory.StartTime)) }
|
||||
} else {
|
||||
{ formatDuration(time.Since(data.JobHistory.StartTime)) } (ongoing)
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-upload mr-1"></i> Data Transferred
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-file mr-1"></i> Files Transferred
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ fmt.Sprint(data.JobHistory.FilesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-calendar-day mr-1"></i> Job Schedule
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
{ data.Job.Schedule }
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Configuration Details -->
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 class="text-lg leading-6 font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-cog mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
Transfer Configuration
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Type</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
|
||||
{ data.Config.SourceType }
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Type</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100">
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-secondary-100 dark:bg-secondary-700">
|
||||
{ data.Config.DestinationType }
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Source Path</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||
if data.Config.SourceType == "sftp" {
|
||||
{ data.Config.SourceUser } `@` { data.Config.SourceHost }:{ data.Config.SourcePath }
|
||||
} else {
|
||||
{ data.Config.SourcePath }
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Destination Path</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||
if data.Config.DestinationType == "sftp" {
|
||||
data.Config.DestUser@data.Config.DestHost:data.Config.DestinationPath
|
||||
} else {
|
||||
{ data.Config.DestinationPath }
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">File Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono">
|
||||
{ data.Config.FilePattern }
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
if data.Config.ArchiveEnabled {
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400">Archive Path</dt>
|
||||
<dd class="mt-1 text-sm text-secondary-900 dark:text-secondary-100 font-mono bg-secondary-50 dark:bg-secondary-900 p-2 rounded">
|
||||
{ data.Config.ArchivePath }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-clock mr-2 text-gray-400 dark:text-gray-500"></i> Duration
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
if data.JobHistory.EndTime != nil {
|
||||
{ data.JobHistory.EndTime.Sub(data.JobHistory.StartTime).String() }
|
||||
} else {
|
||||
<span class="italic text-gray-500 dark:text-gray-400">In progress</span>
|
||||
}
|
||||
</dl>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Information (if any) -->
|
||||
if data.JobHistory.ErrorMessage != "" {
|
||||
<div class="bg-white dark:bg-secondary-800 shadow overflow-hidden rounded-lg mb-8 border-l-4 border-red-500">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h3 class="text-lg leading-6 font-medium text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
Error Details
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-5 sm:p-6 bg-red-50 dark:bg-red-900/20">
|
||||
<pre class="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap font-mono">{ data.JobHistory.ErrorMessage }</pre>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-upload mr-2 text-gray-400 dark:text-gray-500"></i> Data Transferred
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ formatBytes(data.JobHistory.BytesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
||||
<a href="/jobs" class="btn-secondary text-center flex items-center justify-center">
|
||||
<i class="fas fa-list-ul mr-2"></i>
|
||||
View All Jobs
|
||||
</a>
|
||||
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="btn-primary text-center flex items-center justify-center">
|
||||
<i class="fas fa-edit mr-2"></i>
|
||||
Edit Job
|
||||
</a>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-file mr-2 text-gray-400 dark:text-gray-500"></i> Files Transferred
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ fmt.Sprintf("%d files", data.JobHistory.FilesTransferred) }
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 flex items-center mb-1">
|
||||
<i class="fas fa-calendar-day mr-2 text-gray-400 dark:text-gray-500"></i> Job Schedule
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
{ data.Job.Schedule }
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Transfer Configuration Details -->
|
||||
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-8">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cog mr-3 text-blue-600 dark:text-blue-400"></i>
|
||||
Transfer Configuration
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<dl class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Type</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.SourceType }</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Type</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white">
|
||||
<span class="px-2.5 py-0.5 text-xs font-medium rounded bg-gray-100 dark:bg-gray-700">{ data.Config.DestinationType }</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Source Path</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.SourcePath }</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Destination Path</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded-lg border border-gray-200 dark:border-gray-700">{ data.Config.DestinationPath }</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">File Pattern</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-white font-mono">{ data.Config.FilePattern }</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Information (if any) -->
|
||||
if data.JobHistory.Status == "failed" && data.JobHistory.ErrorMessage != "" {
|
||||
<div class="p-4 mb-8 text-red-800 border-l-4 border-red-300 bg-red-50 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800 rounded-lg">
|
||||
<div class="flex items-center mb-2">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 text-red-600 dark:text-red-500"></i>
|
||||
<h3 class="text-lg font-medium">Error Information</h3>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<pre class="text-sm whitespace-pre-wrap font-mono p-3 bg-white dark:bg-gray-900 rounded-lg border border-red-200 dark:border-red-800">{ data.JobHistory.ErrorMessage }</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mt-8">
|
||||
<a href="/jobs" class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700 inline-flex items-center justify-center">
|
||||
<i class="fas fa-list-ul mr-2"></i> View All Jobs
|
||||
</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", data.Job.ID)) } class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800 inline-flex items-center justify-center">
|
||||
<i class="fas fa-edit mr-2"></i> Edit Job
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// formatDuration formats a duration in a human-readable way
|
||||
|
||||
+442
-90
@@ -6,126 +6,478 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// 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 } 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
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})`);
|
||||
|
||||
// Store data in a way that's accessible to event handlers
|
||||
window.lastDeletedJob = {
|
||||
id: jobID,
|
||||
name: jobName
|
||||
};
|
||||
|
||||
// Add custom marker to track this deletion
|
||||
window.currentlyDeletingJob = true;
|
||||
}
|
||||
|
||||
type JobsData struct {
|
||||
Jobs []db.Job
|
||||
Jobs []db.Job
|
||||
ConfigCount map[uint]int // Maps job ID to number of configs
|
||||
}
|
||||
|
||||
templ Jobs(ctx context.Context, data JobsData) {
|
||||
@LayoutWithContext("Transfer Jobs", ctx) {
|
||||
<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
|
||||
@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");
|
||||
|
||||
// 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) {
|
||||
// Get job data from button attributes
|
||||
const jobId = button.getAttribute('data-job-id');
|
||||
const jobName = button.getAttribute('data-job-name') || `Job #${jobId}`;
|
||||
|
||||
console.log(`Run job clicked for: ${jobName} (ID: ${jobId})`);
|
||||
|
||||
// No loading notification - just listen for the response
|
||||
button.addEventListener('htmx:afterRequest', function(event) {
|
||||
console.log("Run job request completed:", event.detail);
|
||||
if (event.detail.successful) {
|
||||
let displayName = jobName;
|
||||
// Try to extract job name from response headers
|
||||
if (event.detail.headers && event.detail.headers['HX-Job-Name']) {
|
||||
displayName = event.detail.headers['HX-Job-Name'];
|
||||
}
|
||||
console.log(`Showing success notification for job: ${displayName}`);
|
||||
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}`);
|
||||
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) {
|
||||
// Check if this is a DELETE request by examining the URL and method
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
|
||||
console.log(`Request path: ${path}, method: ${method}`);
|
||||
|
||||
// Pattern match for job deletions (e.g., /jobs/123)
|
||||
if (path && method === 'DELETE' && path.match(/^\/jobs\/\d+$/)) {
|
||||
console.log("Detected job deletion request via URL pattern");
|
||||
|
||||
// This is definitely a delete request - store this information
|
||||
window.isJobDeleteRequest = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Track HTMX after-request events for job deletion
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for job deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerJobDelete function
|
||||
window.currentlyDeletingJob ||
|
||||
// Check flag from beforeRequest handler
|
||||
window.isJobDeleteRequest ||
|
||||
// Check URL pattern directly from this event
|
||||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/jobs\/\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 jobName = "Unknown";
|
||||
|
||||
// Try multiple sources for job name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
jobName = event.detail.elt.getAttribute('data-job-name') || jobName;
|
||||
}
|
||||
|
||||
if (jobName === "Unknown" && window.lastDeletedJob) {
|
||||
// Fallback to our stored job info
|
||||
jobName = window.lastDeletedJob.name;
|
||||
}
|
||||
|
||||
console.log(`Showing success notification for deleted job: ${jobName}`);
|
||||
showToast(`Job "${jobName}" deleted successfully`, 'success');
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingJob = false;
|
||||
window.isJobDeleteRequest = false;
|
||||
window.lastDeletedJob = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Track HTMX error events for job deletion
|
||||
document.addEventListener('htmx:responseError', function(event) {
|
||||
console.log("HTMX response error:", event.detail);
|
||||
|
||||
// Similar logic as success but for errors
|
||||
const isDeleteRequest =
|
||||
window.currentlyDeletingJob ||
|
||||
window.isJobDeleteRequest ||
|
||||
(event.detail.pathInfo && event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/jobs\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
if (isDeleteRequest) {
|
||||
console.log("Delete request failed");
|
||||
|
||||
let jobName = "Unknown";
|
||||
|
||||
// Try multiple sources for job name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
jobName = event.detail.elt.getAttribute('data-job-name') || jobName;
|
||||
}
|
||||
|
||||
if (jobName === "Unknown" && window.lastDeletedJob) {
|
||||
// Fallback to our stored job info
|
||||
jobName = window.lastDeletedJob.name;
|
||||
}
|
||||
|
||||
let errorMsg = `Failed to delete job "${jobName}"`;
|
||||
|
||||
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.currentlyDeletingJob = false;
|
||||
window.isJobDeleteRequest = false;
|
||||
window.lastDeletedJob = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="jobs-container" style="min-height: 100vh; background-color: rgb(249, 250, 251);" class="jobs-page bg-gray-50 dark:bg-gray-900">
|
||||
<div class="pb-8 w-full">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-calendar-alt w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Scheduled Jobs
|
||||
</h1>
|
||||
<a href="/jobs/new" class="btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<a href="/jobs/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
New Job
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
if len(data.Jobs) == 0 {
|
||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-tasks text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
|
||||
<i class="fas fa-list text-3xl text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No jobs</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by creating a new transfer job.</p>
|
||||
<h3 class="mt-2 text-lg font-medium text-gray-900 dark:text-white">No jobs</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by creating a new transfer job.</p>
|
||||
<div class="mt-6">
|
||||
<a href="/jobs/new" class="btn-primary">
|
||||
<a href="/jobs/new" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
New Job
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<ul role="list" class="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
for _, job := range data.Jobs {
|
||||
<li>
|
||||
<div class="block hover:bg-secondary-50 dark:hover:bg-secondary-750 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-primary-600 dark:text-primary-400 truncate">
|
||||
if job.Name != "" {
|
||||
{ job.Name }
|
||||
<div class="bg-white dark:bg-gray-800 shadow-md sm:rounded-lg overflow-hidden">
|
||||
<div class="relative overflow-x-auto">
|
||||
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, job := range data.Jobs {
|
||||
<li>
|
||||
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
if job.Name != "" {
|
||||
{ job.Name }
|
||||
} else {
|
||||
{ job.Config.Name }
|
||||
}
|
||||
</p>
|
||||
if job.GetEnabled() {
|
||||
<span class="inline-flex items-center bg-green-100 text-green-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300 ml-2">
|
||||
<i class="fas fa-circle-check text-green-500 dark:text-green-400 mr-1"></i>
|
||||
Active
|
||||
</span>
|
||||
} else {
|
||||
{ job.Config.Name }
|
||||
<span class="inline-flex items-center bg-gray-100 text-gray-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300 ml-2">
|
||||
<i class="fas fa-circle-xmark text-gray-500 dark:text-gray-400 mr-1"></i>
|
||||
Inactive
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
if job.Enabled {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Active
|
||||
</span>
|
||||
} else {
|
||||
<span class="ml-2 px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||
<i class="fas fa-pause-circle mr-1"></i>
|
||||
Inactive
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<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>
|
||||
<button
|
||||
hx-delete={ fmt.Sprintf("/jobs/%d", job.ID) }
|
||||
hx-confirm="Are you sure you want to delete this job?"
|
||||
hx-target="closest li"
|
||||
hx-swap="outerHTML"
|
||||
class="btn-danger btn-sm">
|
||||
<i class="fas fa-trash-alt mr-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex">
|
||||
<p class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-cog flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
Config: { job.Config.Name }
|
||||
</p>
|
||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-calendar-alt flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
Schedule: { job.Schedule }
|
||||
</p>
|
||||
if job.LastRun != nil {
|
||||
<p class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-history flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
if job.NextRun != nil {
|
||||
<div class="mt-2 flex items-center text-sm text-secondary-500 dark:text-secondary-400 sm:mt-0">
|
||||
<i class="fas fa-clock flex-shrink-0 mr-1.5 h-5 w-5 text-secondary-400 dark:text-secondary-500"></i>
|
||||
<p>
|
||||
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<button
|
||||
hx-post={ fmt.Sprintf("/jobs/%d/run", job.ID) }
|
||||
hx-swap="none"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
data-job-id={ fmt.Sprint(job.ID) }
|
||||
data-job-name={ job.Name }
|
||||
onclick="window.runJob(this)">
|
||||
<i class="fas fa-play mr-1"></i>
|
||||
Run Now
|
||||
</button>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/jobs/%d", job.ID)) } class="py-1.5 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
|
||||
<i class="fas fa-pen-to-square mr-1"></i>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
hx-post={ fmt.Sprintf("/jobs/%d/duplicate", job.ID) }
|
||||
hx-swap="innerHTML"
|
||||
hx-target="body"
|
||||
class="text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-4 focus:outline-none focus:ring-indigo-300 font-medium rounded-lg text-xs px-3 py-1.5 text-center inline-flex items-center dark:bg-indigo-700 dark:text-indigo-300 dark:hover:bg-indigo-600 dark:focus:ring-indigo-800"
|
||||
data-job-id={ fmt.Sprint(job.ID) }
|
||||
data-job-name={ job.Name }>
|
||||
<i class="fas fa-clone mr-1"></i>
|
||||
Duplicate
|
||||
</button>
|
||||
<!-- Add delete dialog for each job -->
|
||||
@JobDialog(
|
||||
fmt.Sprintf("delete-job-dialog-%d", job.ID),
|
||||
"Delete Job",
|
||||
fmt.Sprintf("Are you sure you want to delete the job '%s'? This cannot be undone.", determineJobName(job)),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||
"Delete",
|
||||
"delete",
|
||||
job.ID,
|
||||
determineJobName(job),
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ showModal(fmt.Sprintf("delete-job-dialog-%d", job.ID)) }
|
||||
class="focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-1.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900">
|
||||
<i class="fas fa-trash mr-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 sm:flex sm:justify-between">
|
||||
<div class="sm:flex flex-wrap">
|
||||
<p class="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-gear text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||
Configs:
|
||||
<span class="ml-1">
|
||||
if count, ok := data.ConfigCount[job.ID]; ok && count > 1 {
|
||||
{ fmt.Sprintf("%d configurations", count) }
|
||||
} else if job.ConfigID > 0 {
|
||||
{ job.Config.Name }
|
||||
} else {
|
||||
{ "None" }
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-calendar text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||
Schedule: { job.Schedule }
|
||||
</p>
|
||||
if job.LastRun != nil {
|
||||
<p class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0 sm:ml-6">
|
||||
<i class="fas fa-clock text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||
Last Run: { job.LastRun.Format("2006-01-02 15:04:05") }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
if job.NextRun != nil {
|
||||
<div class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
|
||||
<i class="fas fa-hourglass-start text-gray-400 dark:text-gray-500 mr-1.5"></i>
|
||||
<p>
|
||||
Next Run: { job.NextRun.Format("2006-01-02 15:04:05") }
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-info-circle mr-1 text-primary-500"></i>
|
||||
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
||||
</p>
|
||||
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Transfer jobs run according to their schedule and transfer files between configured sources and destinations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set dark background color if in dark mode
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.getElementById('jobs-container').style.backgroundColor = '#111827';
|
||||
}
|
||||
|
||||
// Add event listener for theme changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function() {
|
||||
setTimeout(function() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
document.getElementById('jobs-container').style.backgroundColor = isDark ? '#111827' : 'rgb(249, 250, 251)';
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to determine the job name (reuse this logic to keep it consistent)
|
||||
func determineJobName(job db.Job) string {
|
||||
if job.Name != "" {
|
||||
return job.Name
|
||||
}
|
||||
return job.Config.Name
|
||||
}
|
||||
+431
-344
@@ -2,9 +2,23 @@ package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"github.com/gin-gonic/gin"
|
||||
"time"
|
||||
"github.com/starfleetcptn/gomft/components/shared/toast"
|
||||
)
|
||||
|
||||
// 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,396 +43,469 @@ templ Layout(title string) {
|
||||
|
||||
templ LayoutWithContext(title string, ctx context.Context) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="light h-full">
|
||||
<html lang="en" class="h-full bg-gray-50 dark:bg-gray-900">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
|
||||
<meta name="theme-color" content="#2563eb"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="manifest" href="/static/site.webmanifest">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/>
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/>
|
||||
<link rel="manifest" href="/static/site.webmanifest"/>
|
||||
<title>{ title } - GoMFT</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/static/css/app.css"/>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter var', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'custom': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'custom-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style type="text/tailwindcss">
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 bg-secondary-200 text-secondary-800 rounded-lg hover:bg-secondary-300 transition-all duration-200 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-secondary-300 focus:ring-offset-2 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
|
||||
}
|
||||
.btn-warning {
|
||||
@apply px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2;
|
||||
}
|
||||
.form-input {
|
||||
@apply w-full px-3 py-2 border border-secondary-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 shadow-sm transition-all duration-200 dark:bg-secondary-800 dark:border-secondary-700 dark:text-white;
|
||||
}
|
||||
.form-checkbox {
|
||||
@apply rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:border-secondary-700 dark:bg-secondary-800;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-xl shadow-custom overflow-hidden border border-secondary-200 dark:border-secondary-700 transition-all duration-200 hover:shadow-custom-lg;
|
||||
}
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700 bg-secondary-50 dark:bg-secondary-900;
|
||||
}
|
||||
.card-body {
|
||||
@apply p-6;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100;
|
||||
}
|
||||
.badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100;
|
||||
}
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100;
|
||||
}
|
||||
.badge-info {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100;
|
||||
}
|
||||
.table-container {
|
||||
@apply overflow-hidden rounded-lg shadow-sm border border-secondary-200 dark:border-secondary-700;
|
||||
}
|
||||
.table {
|
||||
@apply min-w-full divide-y divide-secondary-200 dark:divide-secondary-700;
|
||||
}
|
||||
.table thead {
|
||||
@apply bg-secondary-50 dark:bg-secondary-900;
|
||||
}
|
||||
.table th {
|
||||
@apply px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider dark:text-secondary-400;
|
||||
}
|
||||
.table tbody {
|
||||
@apply bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700;
|
||||
}
|
||||
.table td {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-secondary-100;
|
||||
}
|
||||
.nav-link {
|
||||
@apply inline-flex items-center px-1 pt-1 text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200 border-b-2 border-transparent hover:border-primary-500 transition-all duration-200;
|
||||
}
|
||||
.nav-link-active {
|
||||
@apply inline-flex items-center px-1 pt-1 text-primary-600 dark:text-primary-400 border-b-2 border-primary-500;
|
||||
}
|
||||
}
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="/static/dist/fontawesome/css/all.min.css"/>
|
||||
<!-- Application assets -->
|
||||
<link rel="stylesheet" href="/static/dist/app.css"/>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
|
||||
|
||||
/* Dark mode styles */
|
||||
.dark body {
|
||||
@apply bg-secondary-900 text-secondary-100;
|
||||
}
|
||||
.dark .bg-white {
|
||||
@apply bg-secondary-800;
|
||||
}
|
||||
.dark .text-secondary-800 {
|
||||
@apply text-secondary-100;
|
||||
}
|
||||
.dark .text-secondary-700 {
|
||||
@apply text-secondary-200;
|
||||
}
|
||||
.dark .text-secondary-500 {
|
||||
@apply text-secondary-300;
|
||||
}
|
||||
.dark .hover\:text-secondary-700:hover {
|
||||
@apply text-secondary-100;
|
||||
}
|
||||
.dark .hover\:bg-secondary-100:hover {
|
||||
@apply bg-secondary-700;
|
||||
}
|
||||
.dark .bg-secondary-50 {
|
||||
@apply bg-secondary-900;
|
||||
}
|
||||
.dark .bg-secondary-200 {
|
||||
@apply bg-secondary-700;
|
||||
}
|
||||
.dark .shadow-sm {
|
||||
@apply shadow-secondary-900;
|
||||
<script>
|
||||
// Immediate theme application
|
||||
// This script runs before the DOM is fully loaded
|
||||
(function() {
|
||||
const isDark = localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.classList.add('dark');
|
||||
|
||||
// Apply dark theme to containers
|
||||
const containers = ['jobs-container', 'configs-container'];
|
||||
containers.forEach(function(id) {
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
container.classList.add('dark');
|
||||
container.style.backgroundColor = '#111827';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Additional dark mode improvements */
|
||||
.dark .bg-secondary-50 {
|
||||
@apply bg-secondary-900;
|
||||
}
|
||||
.dark .bg-secondary-100 {
|
||||
@apply bg-secondary-800;
|
||||
}
|
||||
.dark .border-secondary-200 {
|
||||
@apply border-secondary-700;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
.pb-mobile-nav {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: theme('colors.secondary.100');
|
||||
border-radius: 4px;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.pb-mobile-nav {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: theme('colors.secondary.800');
|
||||
|
||||
/* Ensure dark mode background extends to full height */
|
||||
body.dark {
|
||||
background-color: #111827; /* gray-900 */
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme('colors.secondary.300');
|
||||
border-radius: 4px;
|
||||
|
||||
/* Force dark background on specific pages */
|
||||
body.dark .configs-page,
|
||||
body.dark .jobs-page {
|
||||
background-color: #111827 !important; /* gray-900 */
|
||||
min-height: 100vh;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: theme('colors.secondary.600');
|
||||
|
||||
/* Ensure dark mode fills entire page */
|
||||
body.dark {
|
||||
background-color: #111827 !important;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme('colors.secondary.400');
|
||||
|
||||
/* Force container backgrounds */
|
||||
#jobs-container.dark, #configs-container.dark {
|
||||
background-color: #111827 !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: theme('colors.secondary.500');
|
||||
|
||||
/* Override any white backgrounds in dark mode */
|
||||
body.dark .bg-white {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col bg-secondary-50 dark:bg-secondary-900 h-full">
|
||||
<body class="min-h-full bg-gray-50 dark:bg-gray-900" style="min-height: 100vh; display: flex; flex-direction: column;" hx-on::after-swap="initFlowbite()">
|
||||
if isLoggedIn(ctx) {
|
||||
<nav class="bg-white dark:bg-secondary-800 shadow-sm sticky top-0 z-10">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<!-- Application Shell -->
|
||||
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
|
||||
<div class="flex flex-col flex-grow pt-5 bg-white dark:bg-gray-800 overflow-y-auto border-r border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center flex-shrink-0 px-4">
|
||||
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
GoMFT
|
||||
</a>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<a href="/dashboard" class="nav-link">
|
||||
<i class="fas fa-tachometer-alt mr-1"></i> Dashboard
|
||||
</div>
|
||||
<nav class="flex-1 px-2 py-4 bg-white dark:bg-gray-800">
|
||||
<div class="space-y-1">
|
||||
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/configs" class="nav-link">
|
||||
<i class="fas fa-cogs mr-1"></i> Configs
|
||||
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Configs
|
||||
</a>
|
||||
<a href="/jobs" class="nav-link">
|
||||
<i class="fas fa-tasks mr-1"></i> Jobs
|
||||
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Jobs
|
||||
</a>
|
||||
<a href="/history" class="nav-link">
|
||||
<i class="fas fa-history mr-1"></i> History
|
||||
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
History
|
||||
</a>
|
||||
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Files
|
||||
</a>
|
||||
if isAdmin(ctx) {
|
||||
<a href="/admin/users" class="nav-link">
|
||||
<i class="fas fa-users mr-1"></i> Users
|
||||
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Administration
|
||||
</p>
|
||||
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Users
|
||||
</a>
|
||||
<a href="/admin/tools" class="nav-link">
|
||||
<i class="fas fa-tools mr-1"></i> Admin Tools
|
||||
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Roles
|
||||
</a>
|
||||
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Audit Logs
|
||||
</a>
|
||||
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Database Tools
|
||||
</a>
|
||||
// Settings Dropdown
|
||||
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
|
||||
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||
// <li>
|
||||
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||
// </li>
|
||||
<li>
|
||||
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Authentication Providers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Notification Services
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<i class="fas fa-sun hidden dark:block"></i>
|
||||
<i class="fas fa-moon block dark:hidden"></i>
|
||||
</button>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<div class="sm:hidden flex items-center">
|
||||
<button
|
||||
id="mobile-menu-button"
|
||||
class="text-secondary-500 dark:text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg text-sm p-2"
|
||||
x-data="{}"
|
||||
@click="$dispatch('toggle-mobile-menu')"
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Mobile menu -->
|
||||
<div class="md:hidden fixed top-0 z-30 w-full">
|
||||
<div class="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<a href="/" class="flex items-center text-primary-600 dark:text-primary-400">
|
||||
<i class="fas fa-exchange-alt text-xl"></i>
|
||||
</a>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button data-drawer-target="mobile-menu" data-drawer-toggle="mobile-menu" class="p-2 text-gray-500 rounded-lg hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bars text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div x-data="{ open: false }" class="relative">
|
||||
<button @click="open = !open" class="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
|
||||
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
|
||||
</div>
|
||||
</button>
|
||||
<div x-show="open"
|
||||
@click.away="open = false"
|
||||
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-secondary-800 dark:ring-secondary-700 animate-fadeIn"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="user-menu-button"
|
||||
tabindex="-1">
|
||||
<div class="px-4 py-2 text-xs text-secondary-500 dark:text-secondary-400 border-b border-secondary-200 dark:border-secondary-700">
|
||||
Signed in as <span class="font-medium">{ getUserEmail(ctx) }</span>
|
||||
</div>
|
||||
<a href="/profile" class="block px-4 py-2 text-sm text-secondary-700 hover:bg-secondary-100 dark:text-secondary-200 dark:hover:bg-secondary-700" role="menuitem">
|
||||
<i class="fas fa-user-circle mr-2"></i> Profile
|
||||
</a>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700" role="menuitem">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
<div
|
||||
x-data="{ open: false }"
|
||||
x-on:toggle-mobile-menu.window="open = !open"
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 transform translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 transform translate-y-0"
|
||||
x-transition:leave-end="opacity-0 transform -translate-y-2"
|
||||
class="sm:hidden bg-white dark:bg-secondary-800 shadow-md"
|
||||
>
|
||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||
<a href="/dashboard" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tachometer-alt mr-2"></i> Dashboard
|
||||
</a>
|
||||
<a href="/configs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-cogs mr-2"></i> Configs
|
||||
</a>
|
||||
<a href="/jobs" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tasks mr-2"></i> Jobs
|
||||
</a>
|
||||
<a href="/history" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-history mr-2"></i> History
|
||||
</a>
|
||||
if isAdmin(ctx) {
|
||||
<a href="/admin/users" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-users mr-2"></i> Users
|
||||
<!-- Add this drawer element for mobile menu -->
|
||||
<div id="mobile-menu" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-64 dark:bg-gray-800" tabindex="-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="flex items-center text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
GoMFT
|
||||
</a>
|
||||
<a href="/admin/tools" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tools mr-2"></i> Admin Tools
|
||||
</a>
|
||||
}
|
||||
<a href="/profile" class="block px-3 py-2 rounded-md text-base font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-secondary-700 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-user-circle mr-2"></i> Profile
|
||||
</a>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="w-full text-left block px-3 py-2 rounded-md text-base font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900 hover:text-red-700 dark:hover:text-red-400">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i> Sign out
|
||||
<button type="button" data-drawer-hide="mobile-menu" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Copy the same navigation items from the desktop sidebar -->
|
||||
<nav class="mt-5">
|
||||
<div class="space-y-1">
|
||||
<!-- Copy the same links from the desktop sidebar -->
|
||||
<a href="/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tachometer-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/configs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-cogs mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Configs
|
||||
</a>
|
||||
<a href="/jobs" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-tasks mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Jobs
|
||||
</a>
|
||||
<a href="/history" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-history mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
History
|
||||
</a>
|
||||
<a href="/files" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-file-alt mr-3 text-gray-500 dark:text-gray-400"></i>
|
||||
Files
|
||||
</a>
|
||||
if isAdmin(ctx) {
|
||||
<p class="px-3 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Administration
|
||||
</p>
|
||||
<a href="/admin/users" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-users w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Users
|
||||
</a>
|
||||
<a href="/admin/roles" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-shield w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Roles
|
||||
</a>
|
||||
<a href="/admin/audit" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-clipboard-list w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Audit Logs
|
||||
</a>
|
||||
<a href="/admin/database" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-database w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Database Tools
|
||||
</a>
|
||||
// Settings Dropdown
|
||||
<button type="button" class="flex items-center w-full p-2 text-base text-gray-900 transition duration-75 rounded-lg group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700" aria-controls="dropdown-settings" data-collapse-toggle="dropdown-settings">
|
||||
<i class="fas fa-cog w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
<span class="flex-1 ms-3 text-left rtl:text-right whitespace-nowrap">Settings</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<ul id="dropdown-settings" class="hidden py-2 space-y-2">
|
||||
// <li>
|
||||
// <a href="#" class="flex items-center w-full p-2 text-gray-900 transition duration-75 rounded-lg pl-11 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700">General (Coming Soon)</a>
|
||||
// </li>
|
||||
<li>
|
||||
<a href="/admin/settings/auth-providers" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-user-lock w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Authentication Providers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings/notifications" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-bell w-4 h-4 mr-2 text-gray-500 dark:text-gray-400"></i>
|
||||
Notification Services
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<!-- Main Content -->
|
||||
<div class="md:pl-64 flex flex-col flex-1 w-full bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Main header -->
|
||||
<header class="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div class="flex items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">{ title }</h1>
|
||||
</div>
|
||||
<!-- Theme toggle and user menu -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
id="theme-toggle"
|
||||
type="button"
|
||||
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<i class="fas fa-sun hidden dark:block"></i>
|
||||
<i class="fas fa-moon block dark:hidden"></i>
|
||||
</button>
|
||||
<!-- Notification Bell -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-gray-500 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-700 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600 relative"
|
||||
id="notification-bell"
|
||||
data-dropdown-toggle="notification-dropdown"
|
||||
hx-get="/notifications/dropdown"
|
||||
hx-trigger="click once"
|
||||
hx-target="#notification-dropdown-content"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<span class="sr-only">View notifications</span>
|
||||
<i class="fas fa-bell"></i>
|
||||
<!-- Notification badge will be loaded dynamically -->
|
||||
<div hx-get="/notifications/count" hx-trigger="load, notification-updated from:body" id="notification-count-container"></div>
|
||||
</button>
|
||||
<!-- Notification dropdown -->
|
||||
<div
|
||||
class="hidden overflow-hidden z-50 my-4 max-w-sm md:max-w-md w-full text-base list-none bg-white rounded divide-y divide-gray-100 shadow-lg dark:divide-gray-600 dark:bg-gray-700 rounded-xl"
|
||||
id="notification-dropdown"
|
||||
style="min-width: 320px; width: 100%;"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<div id="notification-dropdown-content">
|
||||
<!-- Content will be loaded dynamically via HTMX -->
|
||||
<div class="block py-2 px-4 text-base font-medium text-center text-gray-700 bg-gray-50 dark:bg-gray-600 dark:text-gray-300">
|
||||
Notifications
|
||||
</div>
|
||||
<div class="py-4 px-4 text-center text-gray-500 dark:text-gray-400">
|
||||
<div class="animate-pulse flex flex-col items-center">
|
||||
<div class="rounded-full bg-gray-200 dark:bg-gray-700 h-12 w-12 mb-2"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
|
||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/notifications" class="block py-2 text-md font-medium text-center text-gray-900 bg-gray-50 hover:bg-gray-100 dark:bg-gray-600 dark:text-white dark:hover:underline">
|
||||
<div class="inline-flex items-center">
|
||||
<svg aria-hidden="true" class="mr-2 w-4 h-4 text-gray-500 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
View all
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- User menu dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="flex text-sm rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||
id="user-menu-button"
|
||||
data-dropdown-toggle="user-dropdown"
|
||||
data-dropdown-placement="bottom-end"
|
||||
>
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<div class="h-8 w-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center dark:bg-primary-900 dark:text-primary-300">
|
||||
<span class="text-sm font-medium">{ getUserInitial(ctx) }</span>
|
||||
</div>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600"
|
||||
id="user-dropdown"
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
<span class="block text-sm text-gray-900 dark:text-white">{ getUserEmail(ctx) }</span>
|
||||
</div>
|
||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||
<li>
|
||||
<a
|
||||
href="/profile"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
|
||||
>
|
||||
<i class="fas fa-user-circle mr-2 w-4"></i>
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-left flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt mr-2 w-4"></i>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Toast Container -->
|
||||
@toast.Container()
|
||||
<!-- Page Content -->
|
||||
<main class="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="py-6 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Notification area -->
|
||||
<div id="notification-area" class="hidden" hx-swap-oob="true"></div>
|
||||
{ children... }
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>GoMFT © { getCurrentYear() }</span>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="https://github.com/starfleetcptn/gomft" class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<a href={ GetReleaseURL() } class="hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tag"></i> { AppVersion }
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<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">
|
||||
{ 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 mobile-nav-container z-10">
|
||||
<div class="grid grid-cols-5 h-16">
|
||||
<a href="/dashboard" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tachometer-alt text-lg"></i>
|
||||
<span class="text-xs mt-1">Dashboard</span>
|
||||
</a>
|
||||
<a href="/configs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-cogs text-lg"></i>
|
||||
<span class="text-xs mt-1">Configs</span>
|
||||
</a>
|
||||
<a href="/jobs" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-tasks text-lg"></i>
|
||||
<span class="text-xs mt-1">Jobs</span>
|
||||
</a>
|
||||
<a href="/history" class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<i class="fas fa-history text-lg"></i>
|
||||
<span class="text-xs mt-1">History</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center text-secondary-500 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
x-data="{}"
|
||||
@click="$dispatch('toggle-mobile-menu')"
|
||||
>
|
||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
||||
<span class="text-xs mt-1">More</span>
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Public page layout -->
|
||||
<div class="flex flex-col min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<main class="flex-grow">
|
||||
{ children... }
|
||||
</main>
|
||||
<footer class="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-screen-2xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>GoMFT © { 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>
|
||||
<!-- Shared Scripts -->
|
||||
@toast.ShowToastJS()
|
||||
<!-- Application scripts -->
|
||||
<script defer src="/static/dist/app.js"></script>
|
||||
<script defer src="/static/dist/init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -455,17 +542,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"
|
||||
}
|
||||
@@ -474,12 +561,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"
|
||||
@@ -487,5 +574,5 @@ func getUserEmail(ctx context.Context) string {
|
||||
|
||||
// Helper function to get current year
|
||||
func getCurrentYear() string {
|
||||
return "2025"
|
||||
}
|
||||
return time.Now().Format("2006")
|
||||
}
|
||||
|
||||
+117
-117
@@ -5,133 +5,133 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ Login(ctx context.Context, errorMessage string) {
|
||||
templ Login(ctx context.Context, errorMessage string, hasExternalProviders bool) {
|
||||
@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>
|
||||
|
||||
if hasExternalProviders {
|
||||
<!-- External Authentication Providers -->
|
||||
<div id="external-auth-providers" class="mt-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">Or sign in with:</p>
|
||||
<div id="provider-buttons" class="flex flex-col gap-2" hx-get="/auth/providers" hx-trigger="load" hx-target="#provider-buttons">
|
||||
<div class="animate-pulse flex justify-center">
|
||||
<div class="h-10 bg-gray-200 rounded w-full max-w-[200px] dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
// "strconv" // No longer needed here
|
||||
)
|
||||
|
||||
// NotificationDialog component for confirmation dialogs using Flowbite modal
|
||||
templ NotificationDialog(id string, title string, message string, confirmClass string, confirmText string, action string, serviceID uint, serviceName string) {
|
||||
<div id={ id } tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full">
|
||||
<!-- Backdrop -->
|
||||
<div id={ fmt.Sprintf("%s-backdrop", id) } class="fixed inset-0 bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm"></div>
|
||||
<!-- Modal content -->
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
|
||||
<div class="p-6 text-center">
|
||||
if action == "delete" {
|
||||
<i class="fas fa-trash-alt text-red-400 text-3xl mb-4"></i>
|
||||
} else {
|
||||
<i class="fas fa-exclamation-triangle text-yellow-400 text-3xl mb-4"></i>
|
||||
}
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{ message }</h3>
|
||||
<button
|
||||
type="button"
|
||||
class={ confirmClass }
|
||||
hx-delete={ fmt.Sprintf("/admin/settings/notifications/%d", serviceID) }
|
||||
hx-target="body"
|
||||
data-service-name={ serviceName }
|
||||
data-service-id={ fmt.Sprint(serviceID) }
|
||||
id={ fmt.Sprintf("delete-btn-%d", serviceID) }
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("triggerServiceDelete('%s', %d, '%s')", id, serviceID, serviceName)} }>
|
||||
{ confirmText }
|
||||
</button>
|
||||
<button type="button" onclick={ templ.ComponentScript{Call: fmt.Sprintf("closeModal('%s')", id)} } class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Scripts (triggerServiceDelete, closeModal, showModal) are now expected to be defined globally or in the calling template (e.g., list.templ).
|
||||
@@ -0,0 +1,20 @@
|
||||
package dialog
|
||||
|
||||
// DialogScripts provides the JavaScript function specific to the notification delete confirmation dialog.
|
||||
templ DialogScripts() {
|
||||
<script type="text/javascript">
|
||||
// Called when the delete confirmation button is clicked.
|
||||
// Primarily closes the modal; the actual delete is handled by hx-delete.
|
||||
function triggerServiceDelete(dialogId, serviceId, serviceName) {
|
||||
console.log(`Confirmed delete for service: ${serviceName} (ID: ${serviceId}). Closing modal: ${dialogId}`);
|
||||
// Call the global closeModal function defined elsewhere (e.g., app.js)
|
||||
if (typeof closeModal === 'function') {
|
||||
closeModal(dialogId);
|
||||
} else {
|
||||
console.error('Global closeModal function not found.');
|
||||
}
|
||||
// Optional: Show a "Deleting..." toast here if desired.
|
||||
// The hx-delete attribute on the button will trigger the actual backend request.
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// No utils needed for this specific template yet
|
||||
)
|
||||
|
||||
templ EmailFields(data types.NotificationFormData) {
|
||||
<!-- TODO: Populate value attributes if editing an email service -->
|
||||
<div id="email_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="smtp_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Host</label>
|
||||
<input type="text" id="smtp_host" name="smtp_host" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="smtp.example.com"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="smtp_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Port</label>
|
||||
<input type="number" id="smtp_port" name="smtp_port" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="587"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="smtp_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Username</label>
|
||||
<input type="text" id="smtp_username" name="smtp_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="user@example.com"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="smtp_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SMTP Password</label>
|
||||
<input type="password" id="smtp_password" name="smtp_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="from_email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">From Email</label>
|
||||
<input type="email" id="from_email" name="from_email" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="notifications@example.com"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ GotifyFields(data types.NotificationFormData) {
|
||||
<div id="gotify_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="gotify_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Gotify Server URL</label>
|
||||
if data.NotificationService.GotifyURL != "" {
|
||||
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value={ data.NotificationService.GotifyURL }/>
|
||||
} else {
|
||||
<input type="url" id="gotify_url" name="gotify_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://gotify.example.com" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">URL of your Gotify server</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Application Token</label>
|
||||
if data.NotificationService.GotifyToken != "" {
|
||||
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value={ data.NotificationService.GotifyToken }/>
|
||||
} else {
|
||||
<input type="text" id="gotify_token" name="gotify_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="A-M-XiEQj.zX5d" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Find this in your Gotify application settings</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||
<select id="gotify_priority" name="gotify_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="0">Low (0)</option>
|
||||
if data.NotificationService.GotifyPriority != "" && data.NotificationService.GotifyPriority == "5" {
|
||||
<option value="5" selected="selected">Normal (5)</option>
|
||||
} else {
|
||||
<option value="5">Normal (5)</option>
|
||||
}
|
||||
<option value="8">High (8)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
|
||||
if data.NotificationService.GotifyTitleTemplate != "" {
|
||||
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.GotifyTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="gotify_title_template" name="gotify_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="gotify_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="gotify_message_template"
|
||||
name="gotify_message_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.GotifyMessageTemplate != "" {
|
||||
data.NotificationService.GotifyMessageTemplate
|
||||
}</textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Gotify -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-gotify-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Gotify configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ NtfyFields(data types.NotificationFormData) {
|
||||
<div id="ntfy_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_server" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Ntfy Server</label>
|
||||
if data.NotificationService.NtfyServer != "" {
|
||||
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value={ data.NotificationService.NtfyServer }/>
|
||||
} else {
|
||||
<input type="url" id="ntfy_server" name="ntfy_server" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://ntfy.sh" value="https://ntfy.sh"/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">The Ntfy server URL (default: ntfy.sh)</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_topic" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Topic</label>
|
||||
if data.NotificationService.NtfyTopic != "" {
|
||||
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value={ data.NotificationService.NtfyTopic }/>
|
||||
} else {
|
||||
<input type="text" id="ntfy_topic" name="ntfy_topic" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="your-unique-topic" value="gomft"/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Choose a unique, unguessable topic name</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||
<select id="ntfy_priority" name="ntfy_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="1">Low (1)</option>
|
||||
if data.NotificationService.NtfyPriority == "3" {
|
||||
<option value="3" selected="selected">Default (3)</option>
|
||||
} else {
|
||||
<option value="3">Default (3)</option>
|
||||
}
|
||||
<option value="4">High (4)</option>
|
||||
<option value="5">Urgent (5)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_username" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username (Optional)</label>
|
||||
if data.NotificationService.NtfyUsername != "" {
|
||||
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value={ data.NotificationService.NtfyUsername }/>
|
||||
} else {
|
||||
<input type="text" id="ntfy_username" name="ntfy_username" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Username for protected topics" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password (Optional)</label>
|
||||
if data.NotificationService.NtfyPassword != "" {
|
||||
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value={ data.NotificationService.NtfyPassword }/>
|
||||
} else {
|
||||
<input type="password" id="ntfy_password" name="ntfy_password" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Password for protected topics" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
|
||||
if data.NotificationService.NtfyTitleTemplate != "" {
|
||||
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.NtfyTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="ntfy_title_template" name="ntfy_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="ntfy_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="ntfy_message_template"
|
||||
name="ntfy_message_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.NtfyMessageTemplate != "" {
|
||||
data.NotificationService.NtfyMessageTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Ntfy -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-ntfy-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Ntfy configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ PushbulletFields(data types.NotificationFormData) {
|
||||
<div id="pushbullet_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_api_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Key</label>
|
||||
if data.NotificationService.PushbulletAPIKey != "" {
|
||||
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value={ data.NotificationService.PushbulletAPIKey }/>
|
||||
} else {
|
||||
<input type="text" id="pushbullet_api_key" name="pushbullet_api_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="o.XyzAbCdEfGhIjKlMnOpQrSt" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get your API key from <a href="https://www.pushbullet.com/#settings/account" target="_blank" class="text-blue-500 hover:underline">Pushbullet Account Settings</a></p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_device_iden" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Identifier (Optional)</label>
|
||||
if data.NotificationService.PushbulletDeviceID != "" {
|
||||
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushbulletDeviceID }/>
|
||||
} else {
|
||||
<input type="text" id="pushbullet_device_iden" name="pushbullet_device_iden" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Title Template</label>
|
||||
if data.NotificationService.PushbulletTitleTemplate != "" {
|
||||
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushbulletTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="pushbullet_title_template" name="pushbullet_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{{job.name}} {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushbullet_body_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="pushbullet_body_template"
|
||||
name="pushbullet_body_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.PushbulletBodyTemplate != "" {
|
||||
data.NotificationService.PushbulletBodyTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Pushbullet -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-pushbullet-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Pushbullet configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ PushoverFields(data types.NotificationFormData) {
|
||||
<div id="pushover_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="pushover_app_token" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">API Token/Key</label>
|
||||
if data.NotificationService.PushoverAPIToken != "" {
|
||||
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value={ data.NotificationService.PushoverAPIToken }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_app_token" name="pushover_app_token" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your application's API token/key from <a href="https://pushover.net/apps" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_user_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">User Key</label>
|
||||
if data.NotificationService.PushoverUserKey != "" {
|
||||
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value={ data.NotificationService.PushoverUserKey }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_user_key" name="pushover_user_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" value=""/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Your user key from <a href="https://pushover.net/" target="_blank" class="text-blue-500 hover:underline">Pushover Dashboard</a></p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_device" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Device Name (Optional)</label>
|
||||
if data.NotificationService.PushoverDevice != "" {
|
||||
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value={ data.NotificationService.PushoverDevice }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_device" name="pushover_device" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Leave empty to send to all devices" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_priority" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Priority</label>
|
||||
<select id="pushover_priority" name="pushover_priority" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="-2">Lowest (-2)</option>
|
||||
<option value="-1">Low (-1)</option>
|
||||
if data.NotificationService.PushoverPriority != "" && data.NotificationService.PushoverPriority == "0" {
|
||||
<option value="0" selected="selected">Normal (0)</option>
|
||||
} else {
|
||||
<option value="0">Normal (0)</option>
|
||||
}
|
||||
<option value="1">High (1)</option>
|
||||
<option value="2">Emergency (2)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_sound" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Sound</label>
|
||||
<select id="pushover_sound" name="pushover_sound" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="pushover">Pushover (default)</option>
|
||||
<option value="bike">Bike</option>
|
||||
<option value="bugle">Bugle</option>
|
||||
<option value="cashregister">Cash Register</option>
|
||||
<option value="classical">Classical</option>
|
||||
<option value="cosmic">Cosmic</option>
|
||||
<option value="falling">Falling</option>
|
||||
<option value="gamelan">Gamelan</option>
|
||||
<option value="incoming">Incoming</option>
|
||||
<option value="intermission">Intermission</option>
|
||||
<option value="magic">Magic</option>
|
||||
<option value="mechanical">Mechanical</option>
|
||||
<option value="pianobar">Piano Bar</option>
|
||||
<option value="siren">Siren</option>
|
||||
<option value="spacealarm">Space Alarm</option>
|
||||
<option value="tugboat">Tug Boat</option>
|
||||
<option value="alien">Alien Alarm (long)</option>
|
||||
<option value="climb">Climb (long)</option>
|
||||
<option value="persistent">Persistent (long)</option>
|
||||
<option value="echo">Echo (long)</option>
|
||||
<option value="updown">Up Down (long)</option>
|
||||
<option value="vibrate">Vibrate Only</option>
|
||||
<option value="none">None (silent)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_title_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Title Template</label>
|
||||
if data.NotificationService.PushoverTitleTemplate != "" {
|
||||
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" value={ data.NotificationService.PushoverTitleTemplate }/>
|
||||
} else {
|
||||
<input type="text" id="pushover_title_template" name="pushover_title_template" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="'{{job.name}}' {{job.status}}" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="pushover_message_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Message Body Template</label>
|
||||
<textarea
|
||||
id="pushover_message_template"
|
||||
name="pushover_message_template"
|
||||
rows="4"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Job '{{job.name}}' {{job.status}} at {{job.completed_at}}. {{job.file_count}} files transferred ({{job.transfer_bytes}} bytes)."
|
||||
>
|
||||
if data.NotificationService.PushoverMessageTemplate != "" {
|
||||
data.NotificationService.PushoverMessageTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<!-- Test notification button for Pushover -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-pushover-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your Pushover configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package fields
|
||||
|
||||
import (
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
// "github.com/starfleetcptn/gomft/components/notifications/form/utils" // Removed as unused
|
||||
)
|
||||
|
||||
templ WebhookFields(data types.NotificationFormData) {
|
||||
<div id="webhook_fields" class="hidden notification-fields">
|
||||
<div class="mb-6">
|
||||
<label for="webhook_url" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Webhook URL</label>
|
||||
if data.NotificationService.WebhookURL != "" {
|
||||
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value={ data.NotificationService.WebhookURL }/>
|
||||
} else {
|
||||
<input type="url" id="webhook_url" name="webhook_url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="https://api.example.com/webhook" value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="method" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">HTTP Method</label>
|
||||
<select id="method" name="method" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
if data.NotificationService.Method != "" {
|
||||
if data.NotificationService.Method == "POST" {
|
||||
<option value="POST" selected="selected">POST</option>
|
||||
} else {
|
||||
<option value="POST">POST</option>
|
||||
}
|
||||
if data.NotificationService.Method == "PUT" {
|
||||
<option value="PUT" selected="selected">PUT</option>
|
||||
} else {
|
||||
<option value="PUT">PUT</option>
|
||||
}
|
||||
} else {
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="headers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Headers (JSON)</label>
|
||||
if data.NotificationService.Headers != "" {
|
||||
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'>{ data.NotificationService.Headers }</textarea>
|
||||
} else {
|
||||
<textarea id="headers" name="headers" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder='{"Content-Type": "application/json", "Authorization": "Bearer token"}'></textarea>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="payload_template" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Payload Template (JSON)</label>
|
||||
<textarea
|
||||
id="payload_template"
|
||||
name="payload_template"
|
||||
rows="5"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder='{
|
||||
"event": "{{job.event}}",
|
||||
"job": {
|
||||
"id": "{{job.id}}",
|
||||
"name": "{{job.name}}",
|
||||
"status": "{{job.status}}",
|
||||
"message": "{{job.message}}",
|
||||
"started_at": "{{job.started_at}}",
|
||||
"completed_at": "{{job.completed_at}}",
|
||||
"duration_seconds": {{job.duration_seconds}},
|
||||
"config_id": "{{job.config_id}}",
|
||||
"config_name": "{{job.config_name}}",
|
||||
"transfer_bytes": {{job.transfer_bytes}},
|
||||
"file_count": {{job.file_count}}
|
||||
},
|
||||
"instance": {
|
||||
"id": "{{instance.id}}",
|
||||
"name": "{{instance.name}}",
|
||||
"version": "{{instance.version}}",
|
||||
"environment": "{{instance.environment}}"
|
||||
},
|
||||
"timestamp": "{{timestamp}}",
|
||||
"notification_id": "{{notification.id}}"
|
||||
}'
|
||||
>
|
||||
if data.NotificationService.PayloadTemplate != "" {
|
||||
data.NotificationService.PayloadTemplate
|
||||
} </textarea>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Use placeholders for dynamic values. Available variables: job.*, instance.*, timestamp, notification.*</p>
|
||||
</div>
|
||||
// Removed duplicate Event Triggers section - now handled in form.templ
|
||||
<div class="mb-6">
|
||||
<label for="secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key (for signature verification)</label>
|
||||
if data.NotificationService.SecretKey != "" {
|
||||
<input type="text" id="secret_key" name="secret_key" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Optional signature verification key" value={ data.NotificationService.SecretKey }/>
|
||||
}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If provided, all webhooks will include an X-GoMFT-Signature header</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="retry_policy" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Retry Policy</label>
|
||||
<select id="retry_policy" name="retry_policy" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
if data.NotificationService.RetryPolicy != "" {
|
||||
if data.NotificationService.RetryPolicy == "none" {
|
||||
<option value="none" selected="selected">No retries</option>
|
||||
} else {
|
||||
<option value="none">No retries</option>
|
||||
}
|
||||
if data.NotificationService.RetryPolicy == "simple" {
|
||||
<option value="simple" selected="selected">Simple (3 retries)</option>
|
||||
} else {
|
||||
<option value="simple">Simple (3 retries)</option>
|
||||
}
|
||||
if data.NotificationService.RetryPolicy == "exponential" {
|
||||
<option value="exponential" selected="selected">Exponential backoff</option>
|
||||
} else {
|
||||
<option value="exponential">Exponential backoff</option>
|
||||
}
|
||||
} else {
|
||||
<option value="none">No retries</option>
|
||||
<option value="simple">Simple (3 retries)</option>
|
||||
<option value="exponential">Exponential backoff</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Test notification button -->
|
||||
<div class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">Test Configuration</h4>
|
||||
<button
|
||||
type="button"
|
||||
id="test-webhook-btn"
|
||||
hx-post="/admin/settings/notifications/test"
|
||||
hx-trigger="click"
|
||||
hx-target="#test-notification-result"
|
||||
hx-swap="outerHTML"
|
||||
class="px-3 py-2 text-xs font-medium text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 rounded-lg dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-paper-plane mr-1"></i>
|
||||
Send Test Notification
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Send a test notification to verify your configuration works correctly before saving.
|
||||
</p>
|
||||
<div id="test-notification-result" class="mt-3 hidden">
|
||||
<!-- Result will be shown here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package form
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/components" // For LayoutWithContext
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/form/utils"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/form/fields" // Import fields
|
||||
)
|
||||
|
||||
templ NotificationForm(ctx context.Context, data types.NotificationFormData) {
|
||||
@FormScripts() // Include the form-specific scripts
|
||||
@components.LayoutWithContext(utils.GetNotificationFormTitle(data.IsNew), ctx) {
|
||||
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||
|
||||
<div id="notification-form-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="pb-8 w-full max-w-4xl mx-auto">
|
||||
<!-- Success Message (hidden, used for HTMX responses) -->
|
||||
if data.SuccessMessage != "" {
|
||||
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||
}
|
||||
<!-- Error Message (hidden, used for HTMX responses) -->
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="hidden error-message">{ data.ErrorMessage }</div>
|
||||
}
|
||||
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
{ utils.GetNotificationFormTitle(data.IsNew) }
|
||||
</h1>
|
||||
<a href="/admin/settings/notifications" class="flex items-center justify-center text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-4 focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 focus:outline-none dark:focus:ring-gray-700">
|
||||
<i class="fas fa-arrow-left w-4 h-4 mr-2"></i>
|
||||
Back to Notification Services
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add Notification Service Form -->
|
||||
<div class="mb-6 p-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<form id="notification-form"
|
||||
if data.IsNew {
|
||||
hx-post="/admin/settings/notifications"
|
||||
} else {
|
||||
hx-put={ fmt.Sprintf("/admin/settings/notifications/%d", data.NotificationService.ID) }
|
||||
}
|
||||
hx-target="#notification-form-container">
|
||||
<div class="mb-6">
|
||||
<label for="notification_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Notification Type</label>
|
||||
<select id="notification_type" name="type" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">Select a type</option>
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "webhook" {
|
||||
<option value="webhook" selected="selected">Webhook</option>
|
||||
} else {
|
||||
<option value="webhook">Webhook</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "pushbullet" {
|
||||
<option value="pushbullet" selected="selected">Pushbullet</option>
|
||||
} else {
|
||||
<option value="pushbullet">Pushbullet</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "ntfy" {
|
||||
<option value="ntfy" selected="selected">Ntfy</option>
|
||||
} else {
|
||||
<option value="ntfy">Ntfy</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "gotify" {
|
||||
<option value="gotify" selected="selected">Gotify</option>
|
||||
} else {
|
||||
<option value="gotify">Gotify</option>
|
||||
}
|
||||
if data.NotificationService != nil && data.NotificationService.Type == "pushover" {
|
||||
<option value="pushover" selected="selected">Pushover</option>
|
||||
} else {
|
||||
<option value="pushover">Pushover</option>
|
||||
}
|
||||
<option value="email" disabled>Email (Coming Soon)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6 hidden common-fields">
|
||||
<label for="notification_name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||
if data.NotificationService.Name != "" {
|
||||
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value={ data.NotificationService.Name }/>
|
||||
} else {
|
||||
<input type="text" id="notification_name" name="name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="My Notification Service" required value=""/>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-6 hidden common-fields">
|
||||
<label for="notification_description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||
if data.NotificationService.Description != "" {
|
||||
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service">{ data.NotificationService.Description }</textarea>
|
||||
} else {
|
||||
<textarea id="notification_description" name="description" rows="3" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Description for this notification service"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Dynamic fields based on notification type -->
|
||||
@fields.EmailFields(data)
|
||||
@fields.WebhookFields(data)
|
||||
@fields.PushbulletFields(data)
|
||||
@fields.NtfyFields(data)
|
||||
@fields.GotifyFields(data)
|
||||
@fields.PushoverFields(data)
|
||||
|
||||
|
||||
<div class="mb-6 hidden common-fields">
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Event Triggers</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Select the job events that should trigger this notification.</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
for _, event := range []string{"job_start", "job_complete", "job_error"} {
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id={ "trigger_" + event }
|
||||
name="event_triggers[]"
|
||||
type="checkbox"
|
||||
value={ event }
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
if utils.IsEventTriggerSelected(data.NotificationService, event, data.IsNew) {
|
||||
checked
|
||||
}
|
||||
/>
|
||||
<label for={ "trigger_" + event } class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">{ utils.FormatEventTriggerName(event) }</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start mb-6 hidden common-fields">
|
||||
<div class="flex items-center h-5">
|
||||
<input type="hidden" name="is_enabled" value="false">
|
||||
<input
|
||||
id="is_enabled"
|
||||
name="is_enabled"
|
||||
type="checkbox"
|
||||
value="true"
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800"
|
||||
if data.NotificationService != nil && data.NotificationService.IsEnabled {
|
||||
checked
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="is_enabled" class="font-medium text-gray-900 dark:text-white">Enable this notification service</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Check this box to make the service active.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden common-fields">
|
||||
<button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
if data.IsNew {
|
||||
Add Service
|
||||
} else {
|
||||
Save Changes
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Configure your notification service to receive alerts for job events. Different notification types have different configuration options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme-specific background handled by Tailwind classes -->
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package form
|
||||
|
||||
// FormScripts contains JavaScript specific to the notification form page.
|
||||
templ FormScripts() {
|
||||
<script>
|
||||
// Toggle notification fields based on selection
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeSelector = document.getElementById('notification_type');
|
||||
// Ensure typeSelector exists before adding listener
|
||||
if (!typeSelector) {
|
||||
console.warn("Notification type selector not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const allFields = document.querySelectorAll('.notification-fields');
|
||||
const commonFields = document.querySelectorAll('.common-fields');
|
||||
|
||||
function toggleFields() {
|
||||
// Hide all specific fields first
|
||||
allFields.forEach(field => field.classList.add('hidden'));
|
||||
|
||||
// Show/hide common fields based on selection
|
||||
const selectedType = typeSelector.value;
|
||||
if (selectedType) {
|
||||
// Show common fields (name, description, is_enabled, submit)
|
||||
commonFields.forEach(field => field.classList.remove('hidden'));
|
||||
|
||||
// Show the selected type's specific fields
|
||||
const fieldsToShow = document.getElementById(`${selectedType}_fields`);
|
||||
if (fieldsToShow) {
|
||||
fieldsToShow.classList.remove('hidden');
|
||||
}
|
||||
} else {
|
||||
// Hide common fields if no type selected
|
||||
commonFields.forEach(field => field.classList.add('hidden'));
|
||||
}
|
||||
}
|
||||
|
||||
typeSelector.addEventListener('change', toggleFields);
|
||||
|
||||
// Initialize form state on load (if editing or if a type is pre-selected)
|
||||
toggleFields();
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetNotificationFormTitle returns the title for the notification form page.
|
||||
func GetNotificationFormTitle(isNew bool) string {
|
||||
if isNew {
|
||||
return "Add Notification Service"
|
||||
}
|
||||
return "Edit Notification Service"
|
||||
}
|
||||
|
||||
// Contains checks if a string slice contains a specific string.
|
||||
func Contains(slice []string, item string) bool {
|
||||
for _, a := range slice {
|
||||
if a == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BoolToString converts a boolean to its string representation "true" or "false".
|
||||
// Useful for setting HTML attributes that expect string values.
|
||||
func BoolToString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// IsEventTriggerSelected checks if a specific event trigger should be pre-selected.
|
||||
// It now accepts the anonymous struct type defined in types.NotificationFormData.
|
||||
// Defaults to checking 'job_complete' and 'job_error' when creating a new service.
|
||||
func IsEventTriggerSelected(service *struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
IsEnabled bool
|
||||
EventTriggers []string
|
||||
RetryPolicy string
|
||||
WebhookURL string
|
||||
Method string
|
||||
Headers string
|
||||
PayloadTemplate string
|
||||
SecretKey string
|
||||
PushbulletAPIKey string
|
||||
PushbulletDeviceID string
|
||||
PushbulletTitleTemplate string
|
||||
PushbulletBodyTemplate string
|
||||
NtfyServer string
|
||||
NtfyTopic string
|
||||
NtfyPriority string
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyTitleTemplate string
|
||||
NtfyMessageTemplate string
|
||||
GotifyURL string
|
||||
GotifyToken string
|
||||
GotifyPriority string
|
||||
GotifyTitleTemplate string
|
||||
GotifyMessageTemplate string
|
||||
PushoverAPIToken string
|
||||
PushoverUserKey string
|
||||
PushoverDevice string
|
||||
PushoverPriority string
|
||||
PushoverSound string
|
||||
PushoverTitleTemplate string
|
||||
PushoverMessageTemplate string
|
||||
}, event string, isNew bool) bool {
|
||||
if !isNew && service != nil {
|
||||
// Use the Contains helper function
|
||||
return Contains(service.EventTriggers, event)
|
||||
}
|
||||
// Default for new services: check complete and error
|
||||
return isNew && (event == "job_complete" || event == "job_error")
|
||||
}
|
||||
|
||||
// FormatEventTriggerName converts event trigger keys to human-readable names.
|
||||
func FormatEventTriggerName(event string) string {
|
||||
// Replace underscores with spaces and capitalize words
|
||||
name := strings.ReplaceAll(event, "_", " ")
|
||||
name = strings.Title(name) // Use strings.Title for capitalization
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/components"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/dialog"
|
||||
"github.com/starfleetcptn/gomft/components/notifications/types"
|
||||
)
|
||||
|
||||
// List renders the notification services list page.
|
||||
templ List(ctx context.Context, data types.SettingsNotificationsData) {
|
||||
@components.LayoutWithContext("Notification Services", ctx) {
|
||||
<!-- Status and Error Messages (Handled by shared toast component in layout) -->
|
||||
|
||||
<div id="notifications-container" class="notifications-page bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||||
<div class="pb-8 w-full">
|
||||
<!-- Success Message (hidden, used for HTMX responses/toast trigger) -->
|
||||
if data.SuccessMessage != "" {
|
||||
<div class="hidden success-message">{ data.SuccessMessage }</div>
|
||||
}
|
||||
<!-- Error Message (hidden, used for HTMX responses/toast trigger) -->
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="hidden error-message">{ data.ErrorMessage }</div>
|
||||
}
|
||||
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bell w-6 h-6 mr-2 text-blue-500 dark:text-blue-400"></i>
|
||||
Notification Services
|
||||
</h1>
|
||||
<a href="/admin/settings/notifications/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add Notification Service
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- List of Notification Services -->
|
||||
if len(data.NotificationServices) == 0 {
|
||||
<div class="text-center py-8 bg-white dark:bg-gray-800 shadow-md rounded-lg">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900 mb-4">
|
||||
<i class="fas fa-bell text-2xl text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No notification services configured</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">Add a notification service to receive alerts for job events.</p>
|
||||
<a href="/admin/settings/notifications/new" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-plus w-4 h-4 mr-2"></i>
|
||||
Add First Notification Service
|
||||
</a>
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
for _, service := range data.NotificationServices {
|
||||
<li>
|
||||
<div class="block hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
if service.Type == "email" {
|
||||
<div class="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 dark:bg-blue-900 dark:text-blue-400 mr-3">
|
||||
<i class="fas fa-envelope"></i>
|
||||
</div>
|
||||
} else if service.Type == "webhook" {
|
||||
<div class="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 dark:bg-green-900 dark:text-green-400 mr-3">
|
||||
<i class="fas fa-code"></i>
|
||||
</div>
|
||||
} else { // Default icon
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 dark:bg-gray-700 dark:text-gray-400 mr-3">
|
||||
<i class="fas fa-bell"></i>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
{ service.Name }
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{ service.Description }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex space-x-2">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/admin/settings/notifications/%d/edit", service.ID)) }
|
||||
class="text-gray-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 mr-1 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<!-- Add notification delete dialog -->
|
||||
@dialog.NotificationDialog(
|
||||
fmt.Sprintf("delete-notification-dialog-%d", service.ID),
|
||||
"Delete Notification Service",
|
||||
fmt.Sprintf("Are you sure you want to delete the notification service '%s'? This cannot be undone.", service.Name),
|
||||
"text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800",
|
||||
"Delete",
|
||||
"delete",
|
||||
service.ID,
|
||||
service.Name,
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
onclick={ templ.ComponentScript{Call: fmt.Sprintf("showModal('delete-notification-dialog-%d')", service.ID)} }
|
||||
class="text-red-500 bg-white focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 rounded-lg text-sm p-2 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 sm:flex sm:justify-between">
|
||||
<div class="sm:flex flex-col md:flex-row gap-2 md:gap-6">
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class={ "px-2 py-1 text-xs font-medium rounded-full",
|
||||
templ.KV("bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", service.IsEnabled),
|
||||
templ.KV("bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", !service.IsEnabled) }
|
||||
>
|
||||
if service.IsEnabled {
|
||||
Active
|
||||
} else {
|
||||
Disabled
|
||||
}
|
||||
</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 rounded-full">
|
||||
{ service.Type }
|
||||
</span>
|
||||
if len(service.EventTriggers) > 0 && service.Type == "webhook" {
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300 rounded-full">
|
||||
{ fmt.Sprintf("%d triggers", len(service.EventTriggers)) }
|
||||
</span>
|
||||
}
|
||||
if service.SuccessCount > 0 || service.FailureCount > 0 {
|
||||
<span class="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||
{ fmt.Sprintf("%d/%d", service.SuccessCount, service.SuccessCount + service.FailureCount) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if service.Type == "webhook" {
|
||||
<div class="mt-2 md:mt-0 flex items-center space-x-4">
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Events:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||
if len(service.EventTriggers) == 0 {
|
||||
None
|
||||
} else {
|
||||
for i, trigger := range service.EventTriggers {
|
||||
if i > 0 {
|
||||
<span>, </span>
|
||||
}
|
||||
{ trigger }
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Retry:</span>
|
||||
<span class="ml-1 text-gray-900 dark:text-gray-300">
|
||||
if service.RetryPolicy == "" {
|
||||
Default
|
||||
} else {
|
||||
{ service.RetryPolicy }
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="mt-2 md:mt-0 flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<i class="far fa-clock w-4 h-4 mr-1.5 text-gray-400 dark:text-gray-500"></i>
|
||||
<p>
|
||||
Last sent:
|
||||
if service.SuccessCount > 0 {
|
||||
"Recently"
|
||||
} else {
|
||||
"Never"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Help Notice Placeholder -->
|
||||
<div class="mt-8 p-4 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-400 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
Notification services allow the system to send alerts for job events such as completion, errors, or when jobs start.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@dialog.DialogScripts()
|
||||
|
||||
}
|
||||
// Script call removed for now
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package list
|
||||
|
||||
// ListScripts contains JavaScript specific to the notification list page.
|
||||
templ ListScripts() {
|
||||
<script>
|
||||
// Notification system (showToast function is now in shared/toast/toast_js.templ)
|
||||
|
||||
// Track all HTMX events for debugging
|
||||
document.addEventListener('htmx:beforeRequest', function(event) {
|
||||
// Check if this is a DELETE request for a notification service
|
||||
const path = event.detail.path;
|
||||
const method = event.detail.verb;
|
||||
|
||||
console.log(`Request path: ${path}, method: ${method}`);
|
||||
|
||||
// Pattern match for notification service deletions (e.g., /admin/settings/notifications/123)
|
||||
if (path && method === 'DELETE' && path.match(/^\/admin\/settings\/notifications\/\d+$/)) {
|
||||
console.log("Detected notification service deletion request via URL pattern");
|
||||
|
||||
// This is definitely a delete request - store this information
|
||||
window.isServiceDeleteRequest = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Check for notification service deletion multiple ways
|
||||
const isDeleteRequest =
|
||||
// Check global flag from the triggerServiceDelete function
|
||||
window.currentlyDeletingService ||
|
||||
// Check flag from beforeRequest handler
|
||||
window.isServiceDeleteRequest ||
|
||||
// Check URL pattern directly from this event
|
||||
(event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
console.log(`Is delete request: ${isDeleteRequest}`);
|
||||
|
||||
// If this is a successful delete request, show notification
|
||||
if (isDeleteRequest && event.detail.successful) {
|
||||
console.log("Delete request was successful");
|
||||
|
||||
let serviceName = "Unknown";
|
||||
|
||||
// Try multiple sources for service name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||
}
|
||||
|
||||
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||
// Fallback to our stored service info
|
||||
serviceName = window.lastDeletedService.name;
|
||||
}
|
||||
|
||||
console.log(`Showing success notification for deleted service: ${serviceName}`);
|
||||
// Ensure showToast is globally available
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(`Notification service "${serviceName}" deleted successfully`, 'success');
|
||||
} else {
|
||||
console.error("showToast function not found!");
|
||||
}
|
||||
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingService = false;
|
||||
window.isServiceDeleteRequest = false;
|
||||
window.lastDeletedService = null;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:responseError', function(event) {
|
||||
console.log("HTMX response error:", event.detail);
|
||||
|
||||
// Similar logic as success but for errors
|
||||
const isDeleteRequest =
|
||||
window.currentlyDeletingService ||
|
||||
window.isServiceDeleteRequest ||
|
||||
(event.detail.pathInfo &&
|
||||
event.detail.pathInfo.requestPath &&
|
||||
event.detail.pathInfo.requestPath.match(/^\/admin\/settings\/notifications\/\d+$/) &&
|
||||
event.detail.verb === 'DELETE');
|
||||
|
||||
let errorMsg = 'An error occurred';
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
errorMsg = event.detail.xhr.responseText;
|
||||
}
|
||||
|
||||
if (isDeleteRequest) {
|
||||
console.log("Delete request failed");
|
||||
|
||||
let serviceName = "Unknown";
|
||||
|
||||
// Try multiple sources for service name
|
||||
if (event.detail.elt && event.detail.elt.getAttribute) {
|
||||
serviceName = event.detail.elt.getAttribute('data-service-name') || serviceName;
|
||||
}
|
||||
|
||||
if (serviceName === "Unknown" && window.lastDeletedService) {
|
||||
// Fallback to our stored service info
|
||||
serviceName = window.lastDeletedService.name;
|
||||
}
|
||||
|
||||
let specificErrorMsg = `Failed to delete notification service "${serviceName}"`;
|
||||
|
||||
if (event.detail.xhr && event.detail.xhr.responseText) {
|
||||
// Try to provide a more specific error from the response
|
||||
specificErrorMsg = `Error deleting "${serviceName}": ${event.detail.xhr.responseText}`;
|
||||
}
|
||||
|
||||
console.log(`Showing error notification: ${specificErrorMsg}`);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(specificErrorMsg, 'error');
|
||||
} else {
|
||||
console.error("showToast function not found!");
|
||||
}
|
||||
|
||||
|
||||
// Clear flags
|
||||
window.currentlyDeletingService = false;
|
||||
window.isServiceDeleteRequest = false;
|
||||
window.lastDeletedService = null;
|
||||
} else {
|
||||
// General error toast
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(errorMsg, 'error');
|
||||
} else {
|
||||
console.error("showToast function not found!");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle modal hide buttons (This might be better placed globally or in layout if modals are used elsewhere)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// This listener handles closing modals via data-modal-hide attribute
|
||||
// It might conflict or be redundant if Flowbite's JS handles this already.
|
||||
// Consider removing if Flowbite is initialized globally.
|
||||
const hideButtons = document.querySelectorAll('[data-modal-hide]');
|
||||
hideButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const modalId = this.getAttribute('data-modal-hide');
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
const backdrop = document.getElementById(modalId + "-backdrop");
|
||||
if (backdrop) {
|
||||
backdrop.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Show any success or error messages passed via data struct as toasts
|
||||
const successDiv = document.querySelector('.success-message');
|
||||
if (successDiv) {
|
||||
const successMsg = successDiv.textContent.trim();
|
||||
if (successMsg && typeof showToast === 'function') {
|
||||
showToast(successMsg, 'success');
|
||||
} else if (successMsg) {
|
||||
console.error("showToast function not found, cannot display success message:", successMsg);
|
||||
}
|
||||
}
|
||||
|
||||
const errorDiv = document.querySelector('.error-message');
|
||||
if (errorDiv) {
|
||||
const errorMsg = errorDiv.textContent.trim();
|
||||
if (errorMsg && typeof showToast === 'function') {
|
||||
showToast(errorMsg, 'error');
|
||||
} else if (errorMsg) {
|
||||
console.error("showToast function not found, cannot display error message:", errorMsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Script for handling the service deletion trigger
|
||||
function triggerServiceDelete(dialogId, serviceID, serviceName) {
|
||||
// Hide the dialog first
|
||||
closeModal(dialogId); // Reuse closeModal logic
|
||||
|
||||
// Add debugging info
|
||||
console.log(`Notification service deletion triggered for: ${serviceName} (ID: ${serviceID})`);
|
||||
|
||||
// Store data in a way that's accessible to event handlers
|
||||
window.lastDeletedService = {
|
||||
id: serviceID,
|
||||
name: serviceName
|
||||
};
|
||||
|
||||
// Add custom marker to track this deletion
|
||||
window.currentlyDeletingService = true;
|
||||
}
|
||||
|
||||
// Script for closing the modal
|
||||
function closeModal(id) {
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog) {
|
||||
dialog.classList.add("hidden");
|
||||
dialog.classList.remove("flex");
|
||||
}
|
||||
const backdrop = document.getElementById(id + "-backdrop");
|
||||
if (backdrop) {
|
||||
// Instead of removing, hide it to potentially reuse
|
||||
backdrop.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Script for showing the modal
|
||||
function showModal(id) {
|
||||
const dialog = document.getElementById(id);
|
||||
if (dialog) {
|
||||
dialog.classList.remove("hidden");
|
||||
dialog.classList.add("flex"); // Use flex to center content
|
||||
}
|
||||
const backdrop = document.getElementById(id + "-backdrop");
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package types
|
||||
|
||||
// SettingsNotificationsData defines the data needed for the notifications list page
|
||||
type SettingsNotificationsData struct {
|
||||
NotificationServices []NotificationServiceData
|
||||
SuccessMessage string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// NotificationServiceData defines the data for a single service in the list
|
||||
type NotificationServiceData struct {
|
||||
ID uint
|
||||
Name string
|
||||
Type string
|
||||
IsEnabled bool
|
||||
Config map[string]string // Keep for now, might refine later
|
||||
Description string
|
||||
EventTriggers []string
|
||||
PayloadTemplate string
|
||||
SecretKey string
|
||||
RetryPolicy string
|
||||
SuccessCount int
|
||||
FailureCount int
|
||||
}
|
||||
|
||||
// NotificationFormData defines the data needed for the notification add/edit form
|
||||
// TODO: This will be moved from notification_form.templ later
|
||||
type NotificationFormData struct {
|
||||
NotificationService *struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Type string
|
||||
IsEnabled bool
|
||||
EventTriggers []string
|
||||
RetryPolicy string
|
||||
WebhookURL string
|
||||
Method string
|
||||
Headers string
|
||||
PayloadTemplate string
|
||||
SecretKey string
|
||||
PushbulletAPIKey string
|
||||
PushbulletDeviceID string
|
||||
PushbulletTitleTemplate string
|
||||
PushbulletBodyTemplate string
|
||||
NtfyServer string
|
||||
NtfyTopic string
|
||||
NtfyPriority string
|
||||
NtfyUsername string
|
||||
NtfyPassword string
|
||||
NtfyTitleTemplate string
|
||||
NtfyMessageTemplate string
|
||||
GotifyURL string
|
||||
GotifyToken string
|
||||
GotifyPriority string
|
||||
GotifyTitleTemplate string
|
||||
GotifyMessageTemplate string
|
||||
PushoverAPIToken string
|
||||
PushoverUserKey string
|
||||
PushoverDevice string
|
||||
PushoverPriority string
|
||||
PushoverSound string
|
||||
PushoverTitleTemplate string
|
||||
PushoverMessageTemplate string
|
||||
}
|
||||
IsNew bool
|
||||
SuccessMessage string
|
||||
ErrorMessage string
|
||||
}
|
||||
+255
-70
@@ -5,53 +5,225 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// Dialog component for 2FA disable confirmation with Flowbite styling
|
||||
templ TwoFactorDisableDialog() {
|
||||
<div id="disable-2fa-dialog" tabindex="-1" aria-hidden="true" class="hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full inset-0 overflow-y-auto overflow-x-hidden bg-gray-900/50 dark:bg-gray-900/80 backdrop-blur-sm">
|
||||
<div class="relative p-4 w-full max-w-md max-h-full mx-auto">
|
||||
<div class="relative bg-white rounded-lg shadow-xl dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-5 text-center">
|
||||
<div class="h-14 w-14 rounded-full bg-yellow-100 dark:bg-yellow-900 p-2 flex items-center justify-center mx-auto mb-5">
|
||||
<i class="fas fa-shield-alt text-yellow-500 dark:text-yellow-300 text-xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-5 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Disable Two-Factor Authentication
|
||||
</h3>
|
||||
<p class="mb-6 text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to disable two-factor authentication? This will make your account less secure.
|
||||
</p>
|
||||
<div class="mb-6">
|
||||
<label for="current-password-2fa" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-lock mr-1"></i> Current Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="current-password-2fa"
|
||||
name="current_password"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter your current password"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="disable-2fa-result" class="mb-5"></div>
|
||||
<div class="flex justify-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
onclick="submitDisable2FA()">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
<button type="button" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" onclick="hideDisable2FADialog()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Profile(ctx context.Context, user db.User) {
|
||||
@LayoutWithContext("Profile", ctx) {
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-user-circle mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<script>
|
||||
// Initialize dialog functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Initializing 2FA dialog functionality');
|
||||
|
||||
// Global functions for dialog control
|
||||
window.hideDisable2FADialog = function() {
|
||||
document.getElementById('disable-2fa-dialog').classList.add('hidden');
|
||||
document.getElementById('disable-2fa-dialog').classList.remove('flex');
|
||||
document.getElementById('current-password-2fa').value = '';
|
||||
document.getElementById('disable-2fa-result').innerHTML = '';
|
||||
};
|
||||
|
||||
window.showDisable2FADialog = function() {
|
||||
console.log('Showing 2FA disable dialog');
|
||||
document.getElementById('disable-2fa-dialog').classList.remove('hidden');
|
||||
document.getElementById('disable-2fa-dialog').classList.add('flex');
|
||||
};
|
||||
|
||||
window.submitDisable2FA = function() {
|
||||
const password = document.getElementById('current-password-2fa').value;
|
||||
if (!password) {
|
||||
document.getElementById('disable-2fa-result').innerHTML = `
|
||||
<div class="p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span>Current password is required</span>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('POST', '/profile/2fa/disable', {
|
||||
target: '#disable-2fa-result',
|
||||
swap: 'innerHTML',
|
||||
values: { current_password: password }
|
||||
});
|
||||
};
|
||||
|
||||
// Close dialog when clicking outside
|
||||
document.getElementById('disable-2fa-dialog').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
hideDisable2FADialog();
|
||||
}
|
||||
});
|
||||
|
||||
// Close dialog on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !document.getElementById('disable-2fa-dialog').classList.contains('hidden')) {
|
||||
hideDisable2FADialog();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@TwoFactorDisableDialog()
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Profile Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-circle w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Profile
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Profile Information Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-id-card mr-2 text-primary-500"></i>
|
||||
Profile Information
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Personal details and application settings.</p>
|
||||
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
|
||||
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-id-card text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Profile Information
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Personal details and application settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-5">
|
||||
<dl class="space-y-6">
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">{ user.Email }</dd>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Email</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">{ user.Email }</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Role</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
if user.IsAdmin {
|
||||
<span class="badge badge-success">
|
||||
<i class="fas fa-user-shield mr-1"></i> Administrator
|
||||
</span>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Roles</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
if user.GetIsAdmin() {
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-blue-900 dark:text-blue-300 inline-flex items-center">
|
||||
<i class="fas fa-user-shield w-3 h-3 mr-1.5"></i> Administrator
|
||||
</span>
|
||||
}
|
||||
for _, role := range user.Roles {
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-gray-700 dark:text-gray-300 inline-flex items-center">
|
||||
<i class="fas fa-user-tag w-3 h-3 mr-1.5"></i> { role.Name }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Permissions</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
for _, role := range user.Roles {
|
||||
for _, perm := range role.GetPermissions() {
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-0.5 rounded dark:bg-green-900 dark:text-green-300">
|
||||
{ perm }
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Two-Factor Authentication</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
if user.TwoFactorEnabled {
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="bg-green-100 text-green-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-green-900 dark:text-green-300 inline-flex items-center">
|
||||
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Enabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<a href="/profile/2fa/backup-codes" class="py-2 px-3 text-xs font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center shadow-sm">
|
||||
<i class="fas fa-key w-3 h-3 mr-1.5"></i>
|
||||
Manage Backup Codes
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800 shadow-sm"
|
||||
onclick="showDisable2FADialog()">
|
||||
<i class="fas fa-times w-3 h-3 mr-1.5"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<span class="badge badge-info">
|
||||
<i class="fas fa-user mr-1"></i> Regular User
|
||||
</span>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div class="flex items-center">
|
||||
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-3 py-1 rounded-full dark:bg-yellow-900 dark:text-yellow-300 inline-flex items-center">
|
||||
<i class="fas fa-shield-alt w-3 h-3 mr-1.5"></i> Disabled
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a href="/profile/2fa/setup" class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-xs px-3 py-2 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm">
|
||||
<i class="fas fa-lock w-3 h-3 mr-1.5"></i>
|
||||
Enable 2FA
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
<dt class="text-sm font-medium text-secondary-500 dark:text-secondary-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
||||
<dd class="text-sm text-secondary-900 dark:text-secondary-100 sm:w-2/3">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 sm:w-1/3 mb-1 sm:mb-0">Theme</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 dark:text-white sm:w-2/3">
|
||||
<form
|
||||
hx-post="/profile/theme"
|
||||
hx-swap="none"
|
||||
class="flex items-center space-x-4">
|
||||
class="flex items-center space-x-5">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -61,9 +233,9 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
checked?={ user.Theme == "light" || user.Theme == "" }
|
||||
hx-trigger="change"
|
||||
hx-post="/profile/theme"
|
||||
class="form-checkbox" />
|
||||
<label for="theme-light" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<i class="fas fa-sun mr-1"></i> Light
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<label for="theme-light" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
<i class="fas fa-sun mr-1.5"></i> Light
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -75,9 +247,9 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
checked?={ user.Theme == "dark" }
|
||||
hx-trigger="change"
|
||||
hx-post="/profile/theme"
|
||||
class="form-checkbox" />
|
||||
<label for="theme-dark" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<i class="fas fa-moon mr-1"></i> Dark
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<label for="theme-dark" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
<i class="fas fa-moon mr-1.5"></i> Dark
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -89,9 +261,9 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
checked?={ user.Theme == "system" }
|
||||
hx-trigger="change"
|
||||
hx-post="/profile/theme"
|
||||
class="form-checkbox" />
|
||||
<label for="theme-system" class="ml-2 block text-sm text-secondary-700 dark:text-secondary-300">
|
||||
<i class="fas fa-desktop mr-1"></i> System
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" />
|
||||
<label for="theme-system" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
<i class="fas fa-desktop mr-1.5"></i> System
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
@@ -102,15 +274,21 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</div>
|
||||
|
||||
<!-- Change Password Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-key mr-2 text-primary-500"></i>
|
||||
Change Password
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Update your password to keep your account secure.</p>
|
||||
<div class="bg-white border border-gray-200 rounded-xl shadow-sm dark:border-gray-700 dark:bg-gray-800 transition-all duration-200 hover:shadow-md">
|
||||
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Change Password
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your password to keep your account secure</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="p-5">
|
||||
<form
|
||||
method="POST"
|
||||
action="/change-password"
|
||||
@@ -119,7 +297,7 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
hx-swap="innerHTML"
|
||||
hx-headers='{"X-Profile-Page": "true"}'
|
||||
hx-indicator="#password-change-indicator"
|
||||
class="space-y-4"
|
||||
class="space-y-5"
|
||||
x-data="{
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
@@ -138,57 +316,57 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
<div id="password-result"></div>
|
||||
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-lock mr-1"></i> Current Password
|
||||
<label for="current-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-lock mr-1.5"></i> Current Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="current-password"
|
||||
name="current_password"
|
||||
x-model="currentPassword"
|
||||
class="form-input pl-10 w-full"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-lock-open mr-1"></i> New Password
|
||||
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-lock-open mr-1.5"></i> New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="new-password"
|
||||
name="new_password"
|
||||
x-model="newPassword"
|
||||
class="form-input pl-10 w-full"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
<i class="fas fa-check-double mr-1"></i> Confirm New Password
|
||||
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<i class="fas fa-check-double mr-1.5"></i> Confirm New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
name="confirm_password"
|
||||
x-model="confirmPassword"
|
||||
class="form-input pl-10 w-full"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
required/>
|
||||
</div>
|
||||
@@ -197,13 +375,13 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary w-full flex justify-center py-3"
|
||||
class="w-full text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 shadow-sm"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Update Password
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -214,9 +392,11 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<div class="p-5 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-xl">
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="mr-3 text-blue-500 flex-shrink-0">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<span>Password must be at least 8 characters with letters, numbers, and special characters</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,10 +404,15 @@ templ Profile(ctx context.Context, user db.User) {
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<div class="inline-flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>All profile changes are securely logged for your protection</span>
|
||||
<div class="mt-8">
|
||||
<div class="p-5 text-sm text-blue-800 rounded-xl bg-blue-50 border border-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:border-blue-800 flex items-start shadow-sm">
|
||||
<div class="mr-3 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>All profile changes are securely logged for your protection. For security reasons, you'll be required to enter your password for sensitive changes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
templ NameField() {
|
||||
<div class="mb-6">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Configuration Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-tag text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="name" id="name" x-model="name" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Transfer configuration name" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Choose a descriptive name to identify this configuration.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ FilePatternFields() {
|
||||
<div class="space-y-6">
|
||||
<div class="mb-6">
|
||||
<label for="file_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">File Pattern</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-filter text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="file_pattern" id="file_pattern" x-model="filePattern"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="*.txt, *.csv" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Glob pattern for files to transfer. Leave empty to transfer all files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="output_pattern" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Output Pattern</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-file-export text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="output_pattern" id="output_pattern" x-model="outputPattern"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="${filename}" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Pattern for output filename. Use variables like ${`filename`}, ${`timestamp`}, ${`date`}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Available variables:</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<span class="block">• ${`filename`} - Original filename without extension (e.g., "report")</span>
|
||||
<span class="block">• ${`ext`} - Original file extension (e.g., "csv")</span>
|
||||
<span class="block">• ${`date:format`} - Current date using Go's time format:</span>
|
||||
<span class="block pl-4">- 2006-01-02 → YYYY-MM-DD</span>
|
||||
<span class="block pl-4">- 20060102 → YYYYMMDD</span>
|
||||
<span class="block pl-4">- 2006-01-02 15:04:05 → YYYY-MM-DD_HH:MM:SS</span>
|
||||
<span class="block italic mt-2">Example: ${`filename`}_${`date:2006-01-02`}_${`ext`} → "report_2023-03-01.csv"</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ ArchiveOptions() {
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="archive_enabled" name="archive_enabled" x-model="archiveEnabled"
|
||||
class="sr-only peer" :value="archiveEnabled ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Enable archiving</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div x-show="archiveEnabled" class="mt-4">
|
||||
<label for="archive_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Archive Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input id="archive_path" name="archive_path" type="text" x-model="archivePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Path to archive files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Files will be moved here after successful transfer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="delete_after_transfer" name="delete_after_transfer" x-model="deleteAfterTransfer"
|
||||
class="sr-only peer" :value="deleteAfterTransfer ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-red-300 dark:peer-focus:ring-red-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-red-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Delete source files after transfer</span>
|
||||
</label>
|
||||
</div>
|
||||
<div x-show="deleteAfterTransfer" class="mt-2">
|
||||
<div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/30 dark:text-red-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span>Warning: This will permanently delete the original files</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="skip_processed_files" name="skip_processed_files" x-model="skipProcessedFiles"
|
||||
class="sr-only peer" :value="skipProcessedFiles ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Skip files that have already been processed</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Files with the same hash that have been successfully processed before will be skipped
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<label for="max_concurrent_transfers" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Concurrent Transfers: <span x-text="maxConcurrentTransfers"></span>
|
||||
</label>
|
||||
<input type="range" id="max_concurrent_transfers" name="max_concurrent_transfers"
|
||||
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||||
min="1" max="32" step="1" x-model="maxConcurrentTransfers">
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Number of files to transfer simultaneously (higher values may improve performance but increase resource usage)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ RcloneFlags(currentCommandID uint) {
|
||||
<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(currentCommandID) // Pass it down
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the rclone command to use for this configuration.
|
||||
</p>
|
||||
|
||||
<!-- Flag container is now rendered directly in ConfigForm -->
|
||||
|
||||
<label for="rclone_flags" class="block mb-2 mt-6 text-sm font-medium text-gray-900 dark:text-white">Additional Rclone Flags</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-flag text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" name="rclone_flags" id="rclone_flags" x-model="rcloneFlags"
|
||||
@change="updateCommandRequirements()"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="--transfers 4 --checkers 8" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Optional: Additional rclone flags for fine-tuning the transfer.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
// New placeholder templ for rclone command options
|
||||
templ RcloneCommandOptions(currentCommandID uint) { // Accept currentCommandID
|
||||
<div
|
||||
hx-get="/api/rclone/commands"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals={ fmt.Sprintf(`{"commandId": %d}`, currentCommandID) }>
|
||||
<!-- Loading placeholder -->
|
||||
<option value="">Loading commands...</option>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ SourceSelection() {
|
||||
<div class="mb-6">
|
||||
<label for="source_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Source Type</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<select id="source_type" name="source_type" x-model="sourceType"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="local">Local</option>
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="ftp">FTP</option>
|
||||
<option value="s3">S3</option>
|
||||
<option value="minio">MinIO</option>
|
||||
<option value="smb">SMB</option>
|
||||
<option value="nextcloud">NextCloud</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
<option value="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ DestinationSelection() {
|
||||
<div class="mb-6">
|
||||
<label for="destination_type" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Destination Type</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<select id="destination_type" name="destination_type" x-model="destinationType"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="local">Local</option>
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="ftp">FTP</option>
|
||||
<option value="s3">S3</option>
|
||||
<option value="minio">MinIO</option>
|
||||
<option value="smb">SMB</option>
|
||||
<option value="nextcloud">NextCloud</option>
|
||||
<option value="webdav">WebDAV</option>
|
||||
<option value="gdrive">Google Drive (BETA)</option>
|
||||
<option value="gphotos">Google Photos (BETA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// RcloneCommandOptionsContent renders the command options organized by category
|
||||
templ RcloneCommandOptionsContent(categoryMap map[string][]db.RcloneCommand, categories []string, currentCommandID uint, commandFlagsJSON string, commandFlagValuesJSON string) { // Add flag JSON strings
|
||||
<select id="command_id" name="command_id" x-model="commandId"
|
||||
hx-get="/api/rclone/command-flags"
|
||||
hx-target="#command-flags-container"
|
||||
hx-trigger="change"
|
||||
hx-include="[name='command_id']"
|
||||
hx-vals={ fmt.Sprintf(`{"commandFlags": %s, "commandFlagValues": %s}`, commandFlagsJSON, commandFlagValuesJSON) }
|
||||
@change="updateCommandRequirements()"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="">Select command...</option>
|
||||
for _, category := range categories {
|
||||
if commands, ok := categoryMap[category]; ok && len(commands) > 0 {
|
||||
<optgroup label={ category }>
|
||||
for _, cmd := range commands {
|
||||
if cmd.ID == currentCommandID {
|
||||
<option value={ fmt.Sprintf("%d", cmd.ID) } selected>{ cmd.Name } - { cmd.Description }</option>
|
||||
} else {
|
||||
<option value={ fmt.Sprintf("%d", cmd.ID) }>{ cmd.Name } - { cmd.Description }</option>
|
||||
}
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
}
|
||||
|
||||
// RcloneCommandFlagsContent renders the command flags for a selected command
|
||||
templ RcloneCommandFlagsContent(command *db.RcloneCommand, selectedFlagsMap map[uint]bool, selectedFlagValues map[uint]string) {
|
||||
if command == nil {
|
||||
<div class="p-4 text-red-500">Command not found</div>
|
||||
return
|
||||
}
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-2">{ command.Name }</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{ command.Description }</p>
|
||||
|
||||
if len(command.Flags) > 0 {
|
||||
<div class="mb-3">
|
||||
<h5 class="font-medium text-gray-900 dark:text-white mb-2">Command Flags:</h5>
|
||||
<div class="space-y-3">
|
||||
for _, flag := range command.Flags {
|
||||
if flag.DataType == "bool" {
|
||||
<!-- Boolean flag (simple checkbox) -->
|
||||
<div class="flex items-start mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={ fmt.Sprintf("flag_%d", flag.ID) }
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
class="mt-0.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||
if selectedFlagsMap[flag.ID] {
|
||||
checked
|
||||
} />
|
||||
<div class="ml-3">
|
||||
<label for={ fmt.Sprintf("flag_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||
{ flag.Name } - { flag.Description }
|
||||
</label>
|
||||
if flag.DefaultValue != "" {
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Default: { flag.DefaultValue }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Non-boolean flag (requires value) -->
|
||||
<div class="flex items-start mb-4 w-full">
|
||||
<div class="w-full">
|
||||
<div class="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
class="mr-2 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:focus:ring-blue-600"
|
||||
data-input-id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
onclick="toggleFlagValue(this)"
|
||||
if selectedFlagsMap[flag.ID] {
|
||||
checked
|
||||
} />
|
||||
<label for={ fmt.Sprintf("flag_enable_%d", flag.ID) } class="font-medium text-gray-900 dark:text-white">
|
||||
{ flag.Name } - { flag.Description }
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-2">
|
||||
@renderFlagInput(flag, selectedFlagValues[flag.ID], selectedFlagsMap[flag.ID]) // Pass value and enabled status
|
||||
if flag.DefaultValue != "" {
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Default: { flag.DefaultValue }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript to handle enabling/disabling flag value inputs -->
|
||||
<script>
|
||||
function toggleFlagValue(checkbox) {
|
||||
const inputId = checkbox.getAttribute('data-input-id');
|
||||
const input = document.getElementById(inputId);
|
||||
|
||||
if (input) {
|
||||
input.disabled = !checkbox.checked;
|
||||
if (checkbox.checked) {
|
||||
// Focus on the input when enabled
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// Also enable/disable the hidden input for flag ID
|
||||
const hiddenId = inputId.replace('flag_value_', 'flag_hidden_');
|
||||
const hiddenInput = document.getElementById(hiddenId);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.disabled = !checkbox.checked;
|
||||
console.log('Toggle hidden input:', hiddenId, checkbox.checked);
|
||||
}
|
||||
|
||||
// For debugging
|
||||
console.log('Toggle flag value:', inputId, checkbox.checked);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all flag inputs on page load
|
||||
// Initialization is now handled by server-side rendering
|
||||
</script>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 text-blue-800 rounded-lg border border-blue-100 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900 text-sm">
|
||||
<div class="flex items-center mb-1">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span class="font-medium">Usage Examples</span>
|
||||
</div>
|
||||
if command.Name == "ls" ||
|
||||
command.Name == "lsd" ||
|
||||
command.Name == "lsl" ||
|
||||
command.Name == "lsjson" ||
|
||||
command.Name == "lsf" ||
|
||||
command.Name == "delete" ||
|
||||
command.Name == "purge" ||
|
||||
command.Name == "rmdirs" ||
|
||||
command.Name == "mkdir" ||
|
||||
command.Name == "touch" ||
|
||||
command.Name == "md5sum" ||
|
||||
command.Name == "sha1sum" ||
|
||||
command.Name == "sha256sum" ||
|
||||
command.Name == "size" ||
|
||||
command.Name == "stat" ||
|
||||
command.Name == "version" {
|
||||
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path</code>
|
||||
} else {
|
||||
<code class="block mt-1 font-mono text-xs overflow-x-auto">rclone { command.Name } [flags] source:path dest:path</code>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-3 bg-gray-50 text-gray-600 rounded-lg border border-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 text-sm">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>This command doesn't have any specific flags.</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Helper function to render appropriate input based on flag data type
|
||||
templ renderFlagInput(flag db.RcloneCommandFlag, value string, enabled bool) {
|
||||
if flag.DataType == "int" {
|
||||
<input
|
||||
type="number"
|
||||
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
value={ value }
|
||||
if !enabled {
|
||||
disabled
|
||||
} />
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
if !enabled {
|
||||
disabled
|
||||
}
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
} else if flag.DataType == "float" {
|
||||
<input
|
||||
type="number"
|
||||
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
step="0.01"
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
value={ value }
|
||||
if !enabled {
|
||||
disabled
|
||||
} />
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
if !enabled {
|
||||
disabled
|
||||
}
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
} else {
|
||||
<!-- Default to text input for string and other types -->
|
||||
<input
|
||||
type="text"
|
||||
id={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
name={ fmt.Sprintf("flag_value_%d", flag.ID) }
|
||||
class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder={ flag.DefaultValue }
|
||||
value={ value }
|
||||
if !enabled {
|
||||
disabled
|
||||
} />
|
||||
<!-- Hidden input to include this flag ID when checked -->
|
||||
<input
|
||||
type="hidden"
|
||||
name="command_flags"
|
||||
value={ fmt.Sprintf("%d", flag.ID) }
|
||||
if !enabled {
|
||||
disabled
|
||||
}
|
||||
id={ fmt.Sprintf("flag_hidden_%d", flag.ID) }
|
||||
data-enable-with={ fmt.Sprintf("flag_enable_%d", flag.ID) }
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package destination contains the UI destination forms for the GoMFT application.
|
||||
// This file serves as a marker for the destination package to ensure it's properly recognized by Go.
|
||||
package destination
|
||||
@@ -0,0 +1,81 @@
|
||||
package destination
|
||||
|
||||
templ FTPDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="ftp.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="21" min="1" max="65535"
|
||||
x-init="if (!destPort || destPort === 0) destPort = 21"
|
||||
x-effect="if (destinationType === 'ftp' && (destPort === 0 || destPort === 22)) destPort = 21" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="dest_passive_mode" name="dest_passive_mode" x-model="destPassiveMode"
|
||||
class="sr-only peer" :value="destPassiveMode ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/remote/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package destination
|
||||
|
||||
templ GoogleDriveDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!useBuiltinAuthDest">
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthDest" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthDest" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/in/drive" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dest_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_drive_id" name="dest_drive_id" x-model="destDriveId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Specific Google Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dest_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_team_drive" name="dest_team_drive" x-model="destTeamDrive"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Team Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared team drive. Leave empty for personal drive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package destination
|
||||
|
||||
templ GooglePhotosDestinationForm() {
|
||||
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthDest', value => {
|
||||
if(value) {
|
||||
destClientId = '';
|
||||
destClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_dest" name="use_builtin_auth_dest" x-model="useBuiltinAuthDest"
|
||||
class="sr-only peer" :value="useBuiltinAuthDest ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthDest }">
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_client_id" name="dest_client_id" x-model="destClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthDest" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_client_secret" name="dest_client_secret" x-model="destClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthDest"
|
||||
placeholder="Google Photos OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="dest_read_only" name="dest_read_only" x-model="destReadOnly"
|
||||
class="sr-only peer" :value="destReadOnly ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only request read-only access to your photos
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_start_year" name="dest_start_year" x-model="destStartYear"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only include photos uploaded after this year
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="dest_include_archived" name="dest_include_archived" x-model="destIncludeArchived"
|
||||
class="sr-only peer" :value="destIncludeArchived ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Include archived photos and videos in media listings
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Album path (e.g., album/my-photos)" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path within Google Photos to download files from
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Required</h3>
|
||||
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Information</h3>
|
||||
<div class="mt-1">
|
||||
<template x-if="useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthDest">
|
||||
<div>
|
||||
<p>To use Google Photos with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Photos API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Important Note About Google Photos</h3>
|
||||
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package destination
|
||||
|
||||
templ LocalDestinationForm() {
|
||||
<div class="space-y-4 mt-4">
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||
:class="{ 'border-red-500 dark:border-red-700': destPathValid === false, 'border-green-500 dark:border-green-700': destPathValid === true }"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
|
||||
<div x-show="destPathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span x-text="destPathError"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="destPathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span x-text="destPathError || 'Path is valid'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package destination
|
||||
|
||||
templ MinIODestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_endpoint" name="dest_endpoint" x-model="destEndpoint" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://minio.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your MinIO server including protocol (http:// or https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your MinIO bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (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-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_region" name="dest_region" x-model="destRegion"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Optional: Specify the region if your MinIO setup requires it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="minioadmin" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your MinIO Access Key for authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="minioadmin" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your MinIO Secret Key for authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Configuration Note</h3>
|
||||
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package destination
|
||||
|
||||
templ NextCloudDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_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="https://nextcloud.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your NextCloud server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud account password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files in NextCloud
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Connection Tip</h3>
|
||||
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package destination
|
||||
|
||||
templ S3DestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_region" name="dest_region" x-model="destRegion" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_bucket" name="dest_bucket" x-model="destBucket" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your S3 bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_access_key" name="dest_access_key" x-model="destAccessKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your AWS Access Key ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_secret_key" name="dest_secret_key" x-model="destSecretKey" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your AWS Secret Access Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package destination
|
||||
|
||||
templ SFTPDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="sftp.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dest_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="dest_port" name="dest_port" x-model="destPort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="22" min="1" max="65535" x-init="if (!destPort || destPort === 0) destPort = 22" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||
<div class="flex items-center mb-4">
|
||||
<input id="dest_auth_password" type="radio" value="password" name="dest_auth_type" x-model="destAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="dest_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input id="dest_auth_key" type="radio" value="key" name="dest_auth_type" x-model="destAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="dest_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="destAuthType === 'password'">
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" x-bind:required="destAuthType === 'password'" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div x-show="destAuthType === 'key'">
|
||||
<label for="dest_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_key_file" name="dest_key_file" x-model="destKeyFile"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/id_rsa" x-bind:required="destAuthType === 'key'" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/remote/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package destination
|
||||
|
||||
templ SMBDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_host" name="dest_host" x-model="destHost" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="192.168.1.100 or server.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
IP address or hostname of the SMB/CIFS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_share" name="dest_share" x-model="destShare" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Documents" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of the shared folder on the server (without leading slashes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_domain" name="dest_domain" x-model="destDomain"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WORKGROUP or DOMAIN" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="smb_username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Username for SMB/CIFS authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="SMB password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Password for SMB/CIFS authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/subfolder" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to a subfolder within the share (Optional). Use forward slashes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Connection Note</h3>
|
||||
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package destination
|
||||
|
||||
templ WebDAVDestinationForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_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="https://webdav.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your WebDAV server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="dest_user" name="dest_user" x-model="destUser" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="dest_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="dest_password" name="dest_password" x-model="destPassword" x-bind:required="requiresDestination"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV account password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="destination_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="destination_path" name="destination_path" x-model="destinationPath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files on the WebDAV server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Integration Note</h3>
|
||||
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/starfleetcptn/gomft/components/providers/common"
|
||||
"github.com/starfleetcptn/gomft/components/providers/source"
|
||||
"github.com/starfleetcptn/gomft/components/providers/destination"
|
||||
)
|
||||
|
||||
// Returns the form ID based on the form type and whether it's a source or destination
|
||||
func formID(formType string, isSource bool) string {
|
||||
if isSource {
|
||||
return "source_config_form"
|
||||
}
|
||||
return "destination_config_form"
|
||||
}
|
||||
|
||||
// Returns a user-friendly display name for the provider
|
||||
func providerDisplayName(provider string) string {
|
||||
switch provider {
|
||||
case "sftp":
|
||||
return "SFTP"
|
||||
case "local":
|
||||
return "Local Filesystem"
|
||||
case "s3":
|
||||
return "Amazon S3"
|
||||
case "ftp":
|
||||
return "FTP"
|
||||
case "azure":
|
||||
return "Azure Blob Storage"
|
||||
default:
|
||||
return strings.Title(provider)
|
||||
}
|
||||
}
|
||||
|
||||
templ ProviderForm(formType string, providers []string, isSource bool) {
|
||||
<form
|
||||
id={formID(formType, isSource)}
|
||||
x-data={fmt.Sprintf("{ %sProvider: '', showAdvanced: false }", formType)}
|
||||
class="space-y-8">
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4">
|
||||
@common.NameField()
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<label for={fmt.Sprintf("%s_provider", formType)} class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Provider Type</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-server text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<select
|
||||
id={fmt.Sprintf("%s_provider", formType)}
|
||||
name={fmt.Sprintf("%s_provider", formType)}
|
||||
x-model={fmt.Sprintf("%sProvider", formType)}
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
|
||||
<option value="" disabled selected>Select provider type</option>
|
||||
for _, provider := range providers {
|
||||
<option value={provider}>{providerDisplayName(provider)}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'sftp'", formType)}>
|
||||
if isSource {
|
||||
@source.SFTPSourceForm()
|
||||
} else {
|
||||
@destination.SFTPDestinationForm()
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'local'", formType)}>
|
||||
if isSource {
|
||||
@source.LocalSourceForm()
|
||||
} else {
|
||||
@destination.LocalDestinationForm()
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 's3'", formType)}>
|
||||
if isSource {
|
||||
@source.S3SourceForm()
|
||||
} else {
|
||||
@destination.S3DestinationForm()
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-6" x-show={fmt.Sprintf("%sProvider === 'ftp'", formType)}>
|
||||
if isSource {
|
||||
@source.FTPSourceForm()
|
||||
} else {
|
||||
@destination.FTPDestinationForm()
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-12" x-show={fmt.Sprintf("%sProvider", formType)}>
|
||||
<div class="mt-6">
|
||||
<label for="show_advanced" class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
<input id="show_advanced" type="checkbox" x-model="showAdvanced" class="sr-only" />
|
||||
<div class="block bg-gray-200 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition"
|
||||
:class="showAdvanced ? 'transform translate-x-6 bg-primary-500' : ''"></div>
|
||||
</div>
|
||||
<div class="ml-3 text-gray-700 font-medium">
|
||||
Show Advanced Options
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div x-show="showAdvanced">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-y-6 gap-x-4 mt-6">
|
||||
@common.FilePatternFields()
|
||||
if isSource {
|
||||
@common.ArchiveOptions()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
script formAlpineInit() {
|
||||
return {
|
||||
initProviderForm() {
|
||||
// Initialize with values if editing existing config
|
||||
if (window.editData && window.editData.configs) {
|
||||
const config = window.editData.configs.find(c =>
|
||||
isSource ? (c.id === window.editData.source_config_id) : (c.id === window.editData.destination_config_id)
|
||||
);
|
||||
|
||||
if (config) {
|
||||
this[formType + 'Provider'] = config.provider;
|
||||
this.name = config.name;
|
||||
|
||||
// Provider-specific fields
|
||||
if (config.provider === 'sftp') {
|
||||
this.host = config.host;
|
||||
this.port = config.port;
|
||||
this.username = config.username;
|
||||
this.path = config.path;
|
||||
|
||||
if (config.key_file && config.key_file !== '') {
|
||||
this.authType = 'key_file';
|
||||
this.keyFile = config.key_file;
|
||||
} else {
|
||||
this.authType = 'password';
|
||||
// Password is not included in edit data for security
|
||||
}
|
||||
} else if (config.provider === 'local') {
|
||||
this.path = config.path;
|
||||
} else if (config.provider === 's3') {
|
||||
this.bucket = config.bucket;
|
||||
this.region = config.region;
|
||||
this.path = config.path;
|
||||
this.accessKey = config.access_key;
|
||||
|
||||
if (config.endpoint && config.endpoint !== '') {
|
||||
this.useCustomEndpoint = true;
|
||||
this.endpoint = config.endpoint;
|
||||
} else {
|
||||
this.useCustomEndpoint = false;
|
||||
}
|
||||
} else if (config.provider === 'ftp') {
|
||||
this.host = config.host;
|
||||
this.port = config.port;
|
||||
this.username = config.username;
|
||||
this.path = config.path;
|
||||
this.useFTPS = config.use_ftps;
|
||||
}
|
||||
|
||||
// Advanced options
|
||||
if (config.include_pattern) this.filePattern = config.include_pattern;
|
||||
if (config.exclude_pattern) this.excludePattern = config.exclude_pattern;
|
||||
|
||||
if (isSource && config.extract_archives) {
|
||||
this.extractArchives = true;
|
||||
this.deleteArchives = config.delete_archives;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
providerChanged() {
|
||||
console.log("Provider changed to: " + this[formType + 'Provider']);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package providers contains the UI providers for the GoMFT application.
|
||||
// This file serves as a marker for the providers package to ensure it's properly recognized by Go.
|
||||
package providers
|
||||
@@ -0,0 +1,79 @@
|
||||
package source
|
||||
|
||||
templ FTPSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">FTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="ftp.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the FTP server</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="21" min="1" max="65535" value="21"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP port number (default: 21)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">FTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="source_passive_mode" name="source_passive_mode" x-model="sourcePassiveMode"
|
||||
class="sr-only peer" :value="sourcePassiveMode ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use Passive Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/remote/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the FTP server</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package source
|
||||
|
||||
templ GoogleDriveSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Drive requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use built-in authentication</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Toggle to use your own Google API credentials instead of the built-in ones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!useBuiltinAuthSource">
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<span>You'll need to create your own Google API project with Drive API enabled and create OAuth credentials. Learn how at <a href="https://developers.google.com/drive/api/quickstart/go" class="font-medium underline hover:text-yellow-700 dark:hover:text-yellow-200" target="_blank">Google API documentation</a>.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client ID" x-bind:required="!useBuiltinAuthSource" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client ID from Google Cloud Console</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client Secret</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Your Google API Client Secret" x-bind:required="!useBuiltinAuthSource" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">OAuth 2.0 Client Secret from Google Cloud Console</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fab fa-google-drive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/in/drive" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to the directory in your Google Drive. Leave empty for the root of your Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_drive_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Drive ID (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-fingerprint text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_drive_id" name="source_drive_id" x-model="sourceDriveId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Specific Google Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared drive. Leave empty to use your personal Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_team_drive" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Team Drive (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-users text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_team_drive" name="source_team_drive" x-model="sourceTeamDrive"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Team Drive ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
For accessing a shared team drive. Leave empty for personal drive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package source
|
||||
|
||||
templ GooglePhotosSourceForm() {
|
||||
<div class="space-y-6 mt-4" x-init="$watch('useBuiltinAuthSource', value => {
|
||||
if(value) {
|
||||
sourceClientId = '';
|
||||
sourceClientSecret = '';
|
||||
}
|
||||
})">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Google Photos requires authentication after creating this configuration. You'll be prompted to authenticate once the configuration is saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="use_builtin_auth_source" name="use_builtin_auth_source" x-model="useBuiltinAuthSource"
|
||||
class="sr-only peer" :value="useBuiltinAuthSource ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Use rclone's built-in Google authentication (recommended)</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Simple one-click authentication using rclone's shared credentials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-bind:class="{ 'opacity-50': useBuiltinAuthSource }">
|
||||
<div class="mb-6">
|
||||
<label for="source_client_id" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client ID
|
||||
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-id-card text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_client_id" name="source_client_id" x-model="sourceClientId"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Photos OAuth Client ID" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client ID from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_client_secret" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Google Client Secret
|
||||
<span x-show="useBuiltinAuthSource" class="text-gray-500 dark:text-gray-400 text-xs font-normal">(Using rclone default)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_client_secret" name="source_client_secret" x-model="sourceClientSecret"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
x-bind:disabled="useBuiltinAuthSource"
|
||||
placeholder="Google Photos OAuth Client Secret" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Client Secret from Google Cloud Console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="source_read_only" name="source_read_only" x-model="sourceReadOnly"
|
||||
class="sr-only peer" :value="sourceReadOnly ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Read-only mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only request read-only access to your photos
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_start_year" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Start Year (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-calendar text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="source_start_year" name="source_start_year" x-model="sourceStartYear"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Only include photos after this year (default: 2000)" min="1900" max="2100" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Only include photos uploaded after this year
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-6">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="source_include_archived" name="source_include_archived" x-model="sourceIncludeArchived"
|
||||
class="sr-only peer" :value="sourceIncludeArchived ? 'true' : 'false'">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-white">Include archived media</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="ms-14 text-sm text-gray-500 dark:text-gray-400">
|
||||
Include archived photos and videos in media listings
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Album Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-images text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Album path (e.g., album/my-photos)" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path within Google Photos to download files from
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Required</h3>
|
||||
<p class="mt-1">After saving this configuration, you'll need to authenticate with Google Photos:</p>
|
||||
<ol class="list-decimal list-inside mt-2 space-y-1">
|
||||
<li>Go to the configurations page</li>
|
||||
<li>Click the "Authenticate" button for this configuration</li>
|
||||
<li>You'll be redirected to Google to grant access permissions</li>
|
||||
<li>After granting permission, you'll be automatically redirected back to GoMFT</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Authentication Information</h3>
|
||||
<div class="mt-1">
|
||||
<template x-if="useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>You're using rclone's built-in authentication, which simplifies the setup process:</p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>No need to create your own Google API credentials</li>
|
||||
<li>Authentication happens through Google's secure OAuth flow</li>
|
||||
<li>Automatic redirection back to GoMFT after authentication</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-yellow-700 dark:text-yellow-300">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Note: The built-in authentication uses shared credentials which have rate limits across all rclone users.
|
||||
If you plan to transfer large amounts of data or run many concurrent transfers, consider creating your own credentials.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!useBuiltinAuthSource">
|
||||
<div>
|
||||
<p>To use Google Photos with your own credentials:</p>
|
||||
<ol class="list-decimal list-inside mt-1 space-y-1">
|
||||
<li>Go to the <a href="https://console.cloud.google.com/" target="_blank" class="font-medium underline hover:text-blue-700 dark:hover:text-blue-300">Google Cloud Console</a></li>
|
||||
<li>Create a project and enable the Google Photos API</li>
|
||||
<li>Create OAuth 2.0 credentials (Client ID & Secret)</li>
|
||||
<li>Set authorized redirect URI to <code class="bg-blue-100 dark:bg-blue-800 px-1.5 py-0.5 rounded text-xs">http://localhost:53682/</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-circle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Important Note About Google Photos</h3>
|
||||
<p class="mt-1">When downloading from Google Photos, be aware that some original metadata may not be preserved. Google Photos processes and may compress some images upon upload.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package source
|
||||
|
||||
templ LocalSourceForm() {
|
||||
<div class="space-y-4 mt-4">
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Local Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
:class="{ 'border-red-500 dark:border-red-700': sourcePathValid === false, 'border-green-500 dark:border-green-700': sourcePathValid === true }"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the local directory containing your files</p>
|
||||
<div x-show="sourcePathValid === false" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span x-text="sourcePathError"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="sourcePathValid === true" class="mt-2 text-sm text-green-600 dark:text-green-400">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span x-text="sourcePathError || 'Path is valid'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package source
|
||||
|
||||
templ MinIOSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your MinIO server details below. You'll need your access key, secret key, and endpoint information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_endpoint" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Endpoint URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_endpoint" name="source_endpoint" x-model="sourceEndpoint" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="https://minio.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your MinIO server including protocol (http:// or https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your MinIO bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Region (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-globe-americas text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_region" name="source_region" x-model="sourceRegion"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Optional: Specify the region if your MinIO setup requires it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="minioadmin" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your MinIO Access Key for authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="minioadmin" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your MinIO Secret Key for authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Configuration Note</h3>
|
||||
<p class="mt-1">Make sure your MinIO server is properly configured with TLS if you're using HTTPS, and that the server is accessible from this machine.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package source
|
||||
|
||||
templ NextCloudSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your NextCloud connection details below. You'll need your server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">NextCloud URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-cloud text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_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="https://nextcloud.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your NextCloud server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your NextCloud account password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files in NextCloud
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Connection Tip</h3>
|
||||
<p class="mt-1">If you have two-factor authentication enabled on your NextCloud account, you'll need to create an app password in your NextCloud security settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package source
|
||||
|
||||
templ S3SourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your AWS S3 bucket details below. You'll need your access key, secret key, and bucket information.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_region" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">AWS Region</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_region" name="source_region" x-model="sourceRegion" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="us-east-1" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
AWS Region where your S3 bucket is located (e.g., us-east-1, eu-west-1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_bucket" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Bucket Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-archive text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_bucket" name="source_bucket" x-model="sourceBucket" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="my-bucket" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of your S3 bucket (case-sensitive)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_access_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Access Key ID</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_access_key" name="source_access_key" x-model="sourceAccessKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your AWS Access Key ID
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_secret_key" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Secret Access Key</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_secret_key" name="source_secret_key" x-model="sourceSecretKey" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your AWS Secret Access Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Path in Bucket</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/files/" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path prefix within the bucket (e.g., "backups/"). Leave empty to access the entire bucket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-shield-alt mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Security Note</h3>
|
||||
<p class="mt-1">It's recommended to use an IAM user with restricted permissions for this configuration. The IAM user should only have access to the specific S3 bucket and operations needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package source
|
||||
|
||||
templ SFTPSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div>
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SFTP Host</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="sftp.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Hostname or IP address of the SFTP server</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_port" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Port</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-network-wired text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="number" id="source_port" name="source_port" x-model="sourcePort"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="22" min="1" max="65535" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP port number (default: 22)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP username for authentication</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Authentication Method</label>
|
||||
<div class="flex items-center mb-4">
|
||||
<input id="source_auth_password" type="radio" value="password" name="source_auth_type" x-model="sourceAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="source_auth_password" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input id="source_auth_key" type="radio" value="key" name="source_auth_type" x-model="sourceAuthType"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="source_auth_key" class="ms-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="sourceAuthType === 'password'">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter password" x-bind:required="sourceAuthType === 'password'" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">SFTP password for authentication</p>
|
||||
</div>
|
||||
|
||||
<div x-show="sourceAuthType === 'key'">
|
||||
<label for="source_key_file" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">SSH Key File</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_key_file" name="source_key_file" x-model="sourceKeyFile"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/id_rsa" x-bind:required="sourceAuthType === 'key'" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Path to the SSH private key file</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/remote/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Full path to the directory on the SFTP server</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package source
|
||||
|
||||
templ SMBSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your SMB/CIFS share connection details below. You'll need server address, share name, and credentials.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Server Address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-server text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_host" name="source_host" x-model="sourceHost" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="192.168.1.100 or server.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
IP address or hostname of the SMB/CIFS server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_share" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Share Name</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-share-alt text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_share" name="source_share" x-model="sourceShare" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Documents" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Name of the shared folder on the server (without leading slashes)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_domain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Domain (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-sitemap text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_domain" name="source_domain" x-model="sourceDomain"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="WORKGROUP or DOMAIN" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Windows domain name (if applicable). Default is 'WORKGROUP' for standalone servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="smb_username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Username for SMB/CIFS authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="SMB password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Password for SMB/CIFS authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Subfolder Path (Optional)</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="path/to/subfolder" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to a subfolder within the share (Optional). Use forward slashes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Connection Note</h3>
|
||||
<p class="mt-1">Make sure the SMB server is accessible from this machine and that the share permissions allow the specified user to read the files.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package source contains the UI source forms for the GoMFT application.
|
||||
// This file serves as a marker for the source package to ensure it's properly recognized by Go.
|
||||
package source
|
||||
@@ -0,0 +1,82 @@
|
||||
package source
|
||||
|
||||
templ WebDAVSourceForm() {
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="p-4 mb-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
<span>Configure your WebDAV connection details below. You'll need the server URL, username, and password.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_host" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">WebDAV URL</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_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="https://webdav.example.com" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full URL to your WebDAV server including protocol (https://)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_user" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-user text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_user" name="source_user" x-model="sourceUser" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="username" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV username
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="password" id="source_password" name="source_password" x-model="sourcePassword" required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="password" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your WebDAV account password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="source_path" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Remote Path</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none">
|
||||
<i class="fas fa-folder-open text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input type="text" id="source_path" name="source_path" x-model="sourcePath"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full ps-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="/path/to/files" />
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Path to your files on the WebDAV server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/30 dark:text-yellow-300" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-lightbulb mr-2 flex-shrink-0"></i>
|
||||
<div>
|
||||
<h3 class="font-medium">Integration Note</h3>
|
||||
<p class="mt-1">WebDAV is supported by many services including ownCloud, Nextcloud, Box, and many others. Make sure your WebDAV URL includes the full path to the WebDAV interface.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+152
-82
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package toast
|
||||
|
||||
templ Container() {
|
||||
<div id="toast-container" class="fixed top-5 right-5 z-50 flex flex-col gap-2"></div>
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package toast
|
||||
|
||||
templ ShowToastJS() {
|
||||
<script>
|
||||
// Notification system
|
||||
function showToast(message, type) {
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
if (!toastContainer) {
|
||||
console.error("Toast container not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'toast-' + type + '-' + Date.now();
|
||||
toast.className = 'flex items-center w-full max-w-xs p-4 mb-4 rounded-lg shadow text-gray-500 bg-white dark:text-gray-400 dark:bg-gray-800 transform translate-y-16 opacity-0 transition-all duration-300 ease-out';
|
||||
toast.role = 'alert';
|
||||
|
||||
// Set toast content based on type
|
||||
let iconClass, bgColorClass, textColorClass;
|
||||
|
||||
if (type === 'success') {
|
||||
iconClass = 'text-green-500 bg-green-100 dark:bg-green-800 dark:text-green-200';
|
||||
bgColorClass = 'text-green-500 dark:text-green-200';
|
||||
textColorClass = 'text-green-500 dark:text-green-200';
|
||||
} else if (type === 'error') {
|
||||
iconClass = 'text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200';
|
||||
bgColorClass = 'text-red-500 dark:text-red-200';
|
||||
textColorClass = 'text-red-500 dark:text-red-200';
|
||||
} else { // Default to info
|
||||
iconClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200';
|
||||
bgColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
textColorClass = 'text-blue-500 dark:text-blue-200';
|
||||
}
|
||||
|
||||
// Create icon div
|
||||
const iconDiv = document.createElement('div');
|
||||
iconDiv.className = `inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${iconClass}`;
|
||||
iconDiv.innerHTML = type === 'success'
|
||||
? '<i class="fas fa-check"></i>'
|
||||
: type === 'error'
|
||||
? '<i class="fas fa-exclamation-circle"></i>'
|
||||
: '<i class="fas fa-info-circle"></i>';
|
||||
|
||||
// Create message div and set text content safely
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'ml-3 text-sm font-normal';
|
||||
messageDiv.textContent = message; // Use textContent for safety
|
||||
|
||||
// Create close button
|
||||
const closeButton = document.createElement('button'); // Keep this declaration
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700';
|
||||
closeButton.setAttribute('data-dismiss-target', `#${toast.id}`);
|
||||
closeButton.setAttribute('aria-label', 'Close');
|
||||
closeButton.innerHTML = `
|
||||
<span class="sr-only">Close</span>
|
||||
<i class="fas fa-times"></i>
|
||||
`;
|
||||
|
||||
// Append elements to the toast
|
||||
toast.appendChild(iconDiv);
|
||||
toast.appendChild(messageDiv);
|
||||
toast.appendChild(closeButton);
|
||||
|
||||
// Add toast to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Trigger animation after a small delay
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-16', 'opacity-0');
|
||||
toast.classList.add('translate-y-0', 'opacity-100');
|
||||
}, 10);
|
||||
|
||||
// Add event listener to the close button we created earlier
|
||||
closeButton.addEventListener('click', function() { // Use the existing closeButton variable
|
||||
// Animate out before removing
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Auto-remove toast after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package components
|
||||
|
||||
templ TestResult(success bool, message string) {
|
||||
if success {
|
||||
<div class="text-green-600 dark:text-green-400 flex items-center">
|
||||
<i class="fas fa-check-circle mr-2"></i>
|
||||
<span>{ message }</span>
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-red-600 dark:text-red-400 flex items-center">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
<span>{ message }</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
import "strings"
|
||||
|
||||
type TwoFactorBackupCodesData struct {
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
SuccessMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorBackupCodes(ctx context.Context, data TwoFactorBackupCodesData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Backup Codes", ctx) {
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Two-Factor Authentication Backup Codes
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
These codes can be used to login if you lose access to your authenticator app
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if data.SuccessMessage != "" {
|
||||
if (strings.Contains(data.SuccessMessage, "IMPORTANT")) {
|
||||
<!-- Important warning alert -->
|
||||
<div class="p-4 mb-6 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2"></i>
|
||||
<span class="font-bold">{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Success alert -->
|
||||
<div class="p-4 mb-6 text-sm text-green-800 rounded-lg bg-green-50 dark:bg-green-900/50 dark:text-green-400 border border-green-200 dark:border-green-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.SuccessMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="space-y-8">
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<!-- Backup codes card -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Your Backup Codes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Store these securely for account recovery</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Security Alert -->
|
||||
<div class="p-4 mb-6 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800" role="alert">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security information</h4>
|
||||
<p class="font-semibold mb-2">These codes are your backup method to access your account.</p>
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<li>Each code can only be used once.</li>
|
||||
<li>Store these codes in a secure password manager.</li>
|
||||
<li>These codes allow access to your account - keep them safe!</li>
|
||||
<li>For security, you can only view codes immediately after generating them.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
if strings.Contains(code, "REDACTED") {
|
||||
<div class="p-2.5 bg-gray-100 dark:bg-gray-700 rounded font-mono text-sm text-center text-gray-500 dark:text-gray-400">
|
||||
{ code }
|
||||
</div>
|
||||
} else {
|
||||
<div class="p-2.5 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded font-mono text-sm text-center font-bold text-blue-900 dark:text-blue-300">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-3 mt-6">
|
||||
if !strings.Contains(data.BackupCodes[0], "REDACTED") {
|
||||
<button
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg focus:outline-none hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
}
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes" class="inline">
|
||||
<button type="submit" class="inline-flex items-center justify-center w-full sm:w-auto text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Generate New Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements that don't contain "REDACTED"
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-blue-50.dark\\:bg-blue-900\\/30')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA BACKUP CODES - KEEP THESE SAFE!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"SECURITY WARNINGS:\n" +
|
||||
"* These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"* Each code can only be used once.\n" +
|
||||
"* Keep these codes in a secure location like a password manager.\n" +
|
||||
"* Treat these codes with the same security as your password.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes-secure.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
} else {
|
||||
<!-- No backup codes state -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 p-8 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-full bg-gray-100 mb-4 dark:bg-gray-700">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">No Backup Codes Available</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">You don't have any backup codes. Generate new ones for account recovery.</p>
|
||||
<form method="POST" action="/profile/2fa/regenerate-codes">
|
||||
<button type="submit" class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
|
||||
<i class="fas fa-sync-alt w-4 h-4 mr-2"></i>
|
||||
Generate Backup Codes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>Backup codes are single-use passwords that allow you to sign in to your account when you don't have access to your authenticator app. Keep these codes in a secure place, such as a password manager or a secure file.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back to Profile Link -->
|
||||
<div class="mt-6 flex justify-start">
|
||||
<a href="/profile" class="inline-flex items-center text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type BackupCodeVerifyData struct {
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorBackupVerify(ctx context.Context, data BackupCodeVerifyData) {
|
||||
@LayoutWithContext("Backup Code Verification", ctx) {
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-key w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Backup Code Verification
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Enter one of your backup codes to verify your identity
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Verification Card -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-yellow-600 dark:text-yellow-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Backup Code Authentication
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Alternative to authenticator app</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Backup Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Enter backup code"
|
||||
required
|
||||
autocomplete="one-time-code"/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-start">
|
||||
<i class="fas fa-exclamation-triangle mr-1.5 mt-0.5 flex-shrink-0"></i>
|
||||
<span>Remember that each backup code can only be used once!</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="!code.trim() || loading">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Verify
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/verify" class="text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
Use authenticator app instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||
<div class="flex items-start text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-2 mt-0.5 text-blue-500"></i>
|
||||
<p>
|
||||
Backup codes were provided when you set up two-factor authentication. Each code can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6 max-w-md mx-auto">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>Backup codes provide an alternative way to verify your identity when you don't have access to your authenticator app. Each code can only be used once for security reasons.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type TwoFactorSetupData struct {
|
||||
QRCodeURL string
|
||||
Secret string
|
||||
BackupCodes []string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorSetup(ctx context.Context, data TwoFactorSetupData) {
|
||||
@LayoutWithContext("Two-Factor Authentication Setup", ctx) {
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Two-Factor Authentication Setup
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Enhance your account security with 2FA by setting up a verification app
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- QR Code Section -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-qrcode text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
1. Scan QR Code
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Scan this QR code with your authenticator app</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 flex flex-col items-center">
|
||||
<div class="mb-6 bg-white p-2 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<img src={ data.QRCodeURL } alt="QR Code" class="h-48 w-48"/>
|
||||
</div>
|
||||
<div class="w-full text-center mb-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Can't scan the QR code? Use this code instead:
|
||||
</p>
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 p-3 font-mono text-sm text-gray-800 dark:text-gray-200 break-all">
|
||||
{ data.Secret }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Section -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
2. Verify Setup
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Enter the code from your authenticator app</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/2fa/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="Enter 6-digit code"
|
||||
required/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Verify and Enable 2FA
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if len(data.BackupCodes) > 0 {
|
||||
<div class="mt-6 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
3. Save Backup Codes
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Store these backup codes in a safe place</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900/50 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800" role="alert">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 mr-2 mt-1"></i>
|
||||
<div>
|
||||
<span class="font-medium">Important:</span> These codes can be used to access your account if you lose your authenticator device. Each code can only be used once. Keep these codes safe and secure.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
|
||||
for _, code := range data.BackupCodes {
|
||||
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 font-mono text-sm text-center text-gray-800 dark:text-gray-200">
|
||||
{ code }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 inline-flex items-center"
|
||||
onclick="downloadBackupCodes(this)">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download Backup Codes
|
||||
</button>
|
||||
<script>
|
||||
function downloadBackupCodes(button) {
|
||||
// Get backup codes from the displayed elements
|
||||
const codes = Array.from(
|
||||
document.querySelectorAll('.bg-gray-50.dark\\:bg-gray-700.font-mono')
|
||||
).map(el => el.textContent.trim());
|
||||
|
||||
// Create content for the file
|
||||
const content =
|
||||
"2FA Backup Codes - Keep these safe!\n" +
|
||||
"=====================================\n\n" +
|
||||
codes.join("\n") +
|
||||
"\n\n" +
|
||||
"Generated: " + new Date().toISOString().split('T')[0] + "\n" +
|
||||
"These codes can be used to access your account if you lose access to your authenticator app.\n" +
|
||||
"Each code can only be used once. Keep these codes safe and secure.";
|
||||
|
||||
// Create blob and download link
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Cleanup
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">About Two-Factor Authentication</h4>
|
||||
<p>After enabling 2FA, you'll need to enter a verification code from your authenticator app when signing in. This adds an extra layer of security to your account even if your password is compromised.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package components
|
||||
|
||||
import "context"
|
||||
|
||||
type TwoFactorVerifyData struct {
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
templ TwoFactorVerify(ctx context.Context, data TwoFactorVerifyData) {
|
||||
@LayoutWithContext("Two-Factor Authentication", ctx) {
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-6 flex flex-col items-center justify-center text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-shield-alt w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Two-Factor Authentication
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6 text-center">
|
||||
Enter the verification code from your authenticator app to continue
|
||||
</p>
|
||||
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="p-4 mb-6 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900/50 dark:text-red-400 border border-red-200 dark:border-red-800" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle flex-shrink-0 mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Verification Card -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="h-10 w-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-key text-blue-600 dark:text-blue-300"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Authentication Verification
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Secure your account with 2FA</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form
|
||||
method="POST"
|
||||
action="/login/verify"
|
||||
class="space-y-4"
|
||||
x-data="{ code: '', loading: false }"
|
||||
@submit="loading = true">
|
||||
<div>
|
||||
<label for="code" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
Authentication Code
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<i class="fas fa-key text-gray-400 dark:text-gray-500"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="code"
|
||||
name="code"
|
||||
x-model="code"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="Enter 6-digit code"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="code.length !== 6 || loading">
|
||||
<span x-show="!loading" class="flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt mr-2"></i>
|
||||
Verify
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Verifying...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/login/backup-code" class="text-sm font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
Use a backup code instead
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-b-lg">
|
||||
<div class="flex items-start text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-info-circle flex-shrink-0 mr-2 mt-0.5 text-blue-500"></i>
|
||||
<p>
|
||||
Lost your device? You can use one of your backup codes instead of the 6-digit code. Backup codes were provided when you set up 2FA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-6 max-w-md mx-auto">
|
||||
<div class="p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900/50 dark:text-blue-400 border border-blue-200 dark:border-blue-800 flex items-start">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-1">Security Information</h4>
|
||||
<p>Two-factor authentication adds an extra layer of security to your account. After entering your password, you'll need to provide a code from your authenticator app to verify your identity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+148
-173
@@ -1,9 +1,9 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"strconv"
|
||||
"context"
|
||||
)
|
||||
|
||||
type UsersData struct {
|
||||
@@ -12,88 +12,98 @@ type UsersData struct {
|
||||
|
||||
templ Users(ctx context.Context, data UsersData) {
|
||||
@LayoutWithContext("User Management", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-users mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
User Management
|
||||
</h1>
|
||||
<a href="/admin/users/new" class="btn-primary">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(data.Users) == 0 {
|
||||
<div class="card p-12 flex flex-col items-center justify-center text-center">
|
||||
<div class="inline-block p-4 rounded-full bg-secondary-100 dark:bg-secondary-700 mb-4">
|
||||
<i class="fas fa-user-slash text-secondary-400 dark:text-secondary-500 text-3xl"></i>
|
||||
</div>
|
||||
<h3 class="mt-2 text-lg font-medium text-secondary-900 dark:text-secondary-100">No users found</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Get started by adding a new user.</p>
|
||||
<div class="mt-6">
|
||||
<a href="/admin/users/new" class="btn-primary">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<div class="mb-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-users w-6 h-6 mr-2 text-blue-500"></i>
|
||||
User Management
|
||||
</h1>
|
||||
<a href="/admin/users/new" class="flex items-center justify-center text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">
|
||||
<i class="fas fa-user-plus w-4 h-4 mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
if len(data.Users) == 0 {
|
||||
<div class="flex p-4 mb-4 text-sm text-yellow-800 rounded-lg bg-yellow-50 dark:bg-yellow-900 dark:text-yellow-300" role="alert">
|
||||
<i class="fas fa-exclamation-triangle flex-shrink-0 inline w-5 h-5 me-3"></i>
|
||||
<span class="sr-only">Info</span>
|
||||
<div>
|
||||
<span class="font-medium">No users found!</span> Click the "Add User" button to create your first user.
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<thead class="bg-secondary-50 dark:bg-secondary-800">
|
||||
</div>
|
||||
} else {
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
|
||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
<i class="fas fa-envelope mr-1"></i> Email
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-envelope w-4 h-4 mr-1"></i>
|
||||
Email
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
<i class="fas fa-user-tag mr-1"></i> Role
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-user-tag w-4 h-4 mr-1"></i>
|
||||
Role
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider">
|
||||
<i class="fas fa-calendar-plus mr-1"></i> Created
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-plus w-4 h-4 mr-1"></i>
|
||||
Created
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="relative px-6 py-3">
|
||||
<th scope="col" class="px-6 py-3">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-secondary-750 divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
<tbody>
|
||||
if len(data.Users) == 0 {
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
|
||||
<td colspan="4" class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
} else {
|
||||
for _, user := range data.Users {
|
||||
<tr class="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-secondary-900 dark:text-secondary-100">{ user.Email }</div>
|
||||
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<td class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{ user.Email }
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
if user.IsAdmin {
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-300">
|
||||
<i class="fas fa-user-shield mr-1"></i> Admin
|
||||
<td class="px-6 py-4">
|
||||
if user.GetIsAdmin() {
|
||||
<span class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300">
|
||||
<i class="fas fa-user-shield w-3 h-3 mr-1"></i> Admin
|
||||
</span>
|
||||
} else {
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-300">
|
||||
<i class="fas fa-user mr-1"></i> User
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-user w-3 h-3 mr-1"></i> User
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<td class="px-6 py-4">
|
||||
{ user.CreatedAt.Format("Jan 02, 2006") }
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a
|
||||
href={ templ.SafeURL("/admin/users/" + strconv.Itoa(int(user.ID)) + "/edit") }
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center me-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
>
|
||||
<i class="fas fa-edit w-3.5 h-3.5 mr-1.5"></i> Edit
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="btn-danger btn-sm"
|
||||
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-3 py-1.5 text-center inline-flex items-center dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
|
||||
hx-delete={ "/admin/users/" + strconv.Itoa(int(user.ID)) }
|
||||
hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
|
||||
hx-target="body"
|
||||
>
|
||||
<i class="fas fa-trash-alt mr-1"></i> Delete
|
||||
<i class="fas fa-trash-alt w-3.5 h-3.5 mr-1.5"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -102,15 +112,18 @@ templ Users(ctx context.Context, data UsersData) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-1 text-primary-500"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Help Notice -->
|
||||
<div class="mt-8">
|
||||
<div class="flex p-4 text-sm text-blue-800 rounded-lg bg-blue-50 dark:bg-blue-900 dark:text-blue-400" role="alert">
|
||||
<i class="fas fa-shield-alt flex-shrink-0 inline w-4 h-4 me-3 mt-0.5"></i>
|
||||
<span class="sr-only">Info</span>
|
||||
<div>
|
||||
User accounts provide secure access to the GoMFT application with role-based permissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,163 +137,125 @@ type UserFormData struct {
|
||||
|
||||
templ UserForm(ctx context.Context, data UserFormData) {
|
||||
@LayoutWithContext("Add User", ctx) {
|
||||
<div class="py-6">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="p-4 md:p-6 2xl:p-10">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-secondary-900 dark:text-secondary-100">
|
||||
<i class="fas fa-user-plus mr-2 text-primary-600 dark:text-primary-400"></i>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-plus w-6 h-6 mr-2 text-blue-500"></i>
|
||||
Add New User
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="mb-6 p-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-red-900 dark:text-red-400" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span>{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 overflow-hidden">
|
||||
<div class="p-6">
|
||||
if data.ErrorMessage != "" {
|
||||
<div class="mb-6">
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<span class="block sm:inline">{ data.ErrorMessage }</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
hx-post="/admin/users"
|
||||
hx-target="body"
|
||||
action="/admin/users/new"
|
||||
method="POST"
|
||||
x-data="{
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
isAdmin: false,
|
||||
loading: false,
|
||||
validate() {
|
||||
return this.email &&
|
||||
this.password &&
|
||||
this.confirmPassword &&
|
||||
this.password === this.confirmPassword;
|
||||
passwordsMatch() {
|
||||
return this.password === this.confirmPassword || this.confirmPassword === '';
|
||||
}
|
||||
}"
|
||||
@htmx:before-request="loading = true"
|
||||
@htmx:after-request="loading = false">
|
||||
>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-secondary-900 dark:text-secondary-100">User Information</h3>
|
||||
<p class="mt-1 text-sm text-secondary-500 dark:text-secondary-400">Create a new user account with appropriate permissions.</p>
|
||||
<h3 class="text-lg font-medium leading-none text-gray-900 dark:text-white">User Information</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Create a new user account with appropriate permissions.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Email</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
x-model="email"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="user@example.com" />
|
||||
</div>
|
||||
<label for="email" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-key text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
x-model="password"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••" />
|
||||
</div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
x-model="password"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">Confirm Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-secondary-400 dark:text-secondary-600"></i>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
id="confirm_password"
|
||||
x-model="confirmPassword"
|
||||
required
|
||||
class="form-input pl-10 w-full rounded-lg border-secondary-300 dark:border-secondary-700 dark:bg-secondary-800 dark:text-secondary-100 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••" />
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-400" x-show="confirmPassword && password !== confirmPassword">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
<label for="password_confirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirm"
|
||||
id="password_confirm"
|
||||
required
|
||||
x-model="confirmPassword"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="mt-1 text-sm text-red-600 dark:text-red-500" x-show="!passwordsMatch() && confirmPassword !== ''">
|
||||
Passwords do not match
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<div class="relative flex items-start">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
id="is_admin"
|
||||
name="is_admin"
|
||||
type="checkbox"
|
||||
x-model="isAdmin"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-700 rounded" />
|
||||
class="w-4 h-4 border border-gray-300 rounded bg-gray-50 focus:ring-3 focus:ring-blue-300 dark:bg-gray-700 dark:border-gray-600 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3 text-sm">
|
||||
<label for="is_admin" class="font-medium text-secondary-700 dark:text-secondary-300">Administrator</label>
|
||||
<p class="text-secondary-500 dark:text-secondary-400">Grant administrative privileges to this user</p>
|
||||
<label for="is_admin" class="font-medium text-gray-900 dark:text-white">Administrator</label>
|
||||
<p class="text-xs font-normal text-gray-500 dark:text-gray-400">Grant administrative privileges to this user</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-secondary-200 dark:border-secondary-700 pt-5">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="/admin/users" class="btn-secondary">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
x-bind:disabled="!validate() || loading">
|
||||
<span x-show="!loading" class="flex items-center">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Create User
|
||||
</span>
|
||||
<span x-show="loading" class="flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/admin/users" class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
x-bind:disabled="!passwordsMatch() && confirmPassword !== ''"
|
||||
>
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-secondary-50 dark:bg-secondary-800 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div class="flex items-center text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<i class="fas fa-shield-alt mr-2 text-primary-500"></i>
|
||||
<span>User accounts provide secure access to the GoMFT application</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-4
@@ -3,22 +3,37 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Set the UID/GID to match your host user for better file permissions
|
||||
# Default is 1000:1000 if not specified
|
||||
UID: ${UID:-1000}
|
||||
GID: ${GID:-1000}
|
||||
# Version information
|
||||
VERSION: ${VERSION:-dev}
|
||||
BUILD_TIME: ${BUILD_TIME:-unknown}
|
||||
COMMIT: ${COMMIT:-unknown}
|
||||
container_name: gomft
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# Persist data directory for SQLite database and configurations
|
||||
# Main data directory - contains DB and configs
|
||||
- gomft-data:/app/data
|
||||
# Separate backups directory
|
||||
- gomft-backups:/app/backups
|
||||
# For development, you can mount the source code
|
||||
# - .:/app
|
||||
environment:
|
||||
- TZ=UTC
|
||||
# Add any environment variables needed for configuration
|
||||
# - GOMFT_DB_PATH=/app/data/gomft.db
|
||||
# - GOMFT_LOG_LEVEL=info
|
||||
- DATA_DIR=/app/data
|
||||
- BACKUP_DIR=/app/backups
|
||||
- LOGS_DIR=/app/data/logs
|
||||
# - LOG_LEVEL=info
|
||||
networks:
|
||||
- gomft-network
|
||||
# For non-root installs, the container needs to run with the same UID
|
||||
# as the host user to access mounted volumes properly
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
|
||||
networks:
|
||||
gomft-network:
|
||||
@@ -26,4 +41,6 @@ networks:
|
||||
|
||||
volumes:
|
||||
gomft-data:
|
||||
driver: local
|
||||
gomft-backups:
|
||||
driver: local
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
#!/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
|
||||
CURRENT_GID=$(getent group ${USERNAME} | cut -d: -f3)
|
||||
CURRENT_UID=$(id -u ${USERNAME})
|
||||
|
||||
if [ "${CURRENT_GID}" != "${PGID}" ] || [ "${CURRENT_UID}" != "${PUID}" ]; then
|
||||
echo "Attempting to update UID/GID to ${PUID}:${PGID} using delete/recreate..."
|
||||
|
||||
# Delete existing user and group, ignoring errors
|
||||
deluser ${USERNAME} > /dev/null 2>&1 || true
|
||||
delgroup ${USERNAME} > /dev/null 2>&1 || true
|
||||
|
||||
# Add group with the specified GID
|
||||
echo "Adding group ${USERNAME} with GID ${PGID}"
|
||||
if ! addgroup -g ${PGID} ${USERNAME}; then
|
||||
echo "⚠️ Failed to add group ${USERNAME} with GID ${PGID}."
|
||||
# Exiting because user creation will likely fail
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add user with the specified UID and GID
|
||||
# Use -G for primary group with adduser in BusyBox
|
||||
# Use -h /app for home directory (consistent with expectations)
|
||||
# Use -s /bin/sh for shell
|
||||
# Use -D for no password (system user)
|
||||
echo "Adding user ${USERNAME} with UID ${PUID}"
|
||||
if ! adduser -u ${PUID} -G ${USERNAME} -h /app -s /bin/sh -D ${USERNAME}; then
|
||||
echo "⚠️ Failed to add user ${USERNAME} with UID ${PUID} and group ${USERNAME}."
|
||||
# Exiting because the application cannot run as the correct user
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the change
|
||||
FINAL_UID=$(id -u ${USERNAME} 2>/dev/null || echo "error")
|
||||
FINAL_GID=$(getent group ${USERNAME} | cut -d: -f3 2>/dev/null || echo "error")
|
||||
|
||||
if [ "${FINAL_UID}" = "${PUID}" ] && [ "${FINAL_GID}" = "${PGID}" ]; then
|
||||
echo "✅ Successfully updated UID/GID to ${PUID}:${PGID}"
|
||||
else
|
||||
echo "⚠️ Verification failed after update. Target: ${PUID}:${PGID}, Actual: ${FINAL_UID}:${FINAL_GID}"
|
||||
# Exiting because the UID/GID is not correct
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "UID/GID ${PUID}:${PGID} already set."
|
||||
fi
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"server_address": ":8080",
|
||||
"data_dir": "/app/data/gomft",
|
||||
"backup_dir": "/app/data/gomft/backups",
|
||||
"jwt_secret": "change_this_to_a_secure_random_string",
|
||||
"email": {
|
||||
"enabled": true,
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"from_email": "gomft@example.com",
|
||||
"from_name": "GoMFT",
|
||||
"reply_to": "",
|
||||
"enable_tls": true,
|
||||
"require_auth": true,
|
||||
"username": "smtp_username",
|
||||
"password": "smtp_password"
|
||||
}
|
||||
}
|
||||
@@ -4,40 +4,59 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.833
|
||||
github.com/gin-contrib/sessions v1.0.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.3
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.35.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.12.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
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
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.25.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
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/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
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user