Compare commits
14 Commits
v1.1.2
..
61e08d7f1a
| Author | SHA1 | Date | |
|---|---|---|---|
| 61e08d7f1a | |||
| 68a8bdfe9e | |||
| 8d12327752 | |||
| dd3170464f | |||
| 4d3240a392 | |||
| 6cbec23c49 | |||
| c315066264 | |||
| 945574906b | |||
| ee10a0f35a | |||
| 1ae54a46e7 | |||
| 918fe80566 | |||
| 073a392f19 | |||
| 703ab3b07d | |||
| b2cc478c85 |
@@ -3,10 +3,14 @@
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"settings": {},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["ms-python.python"]
|
||||
"settings": {},
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"vivaxy.vscode-conventional-commits",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract Version
|
||||
id: get_version
|
||||
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
uses: https://github.com/metcalfc/changelog-generator@v4.6.2
|
||||
with:
|
||||
myToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
VERSION=${{ steps.get_version.outputs.VERSION }}
|
||||
docker build -t cr.jdbnet.co.uk/public/opnsense-sftp:$VERSION \
|
||||
-t cr.jdbnet.co.uk/public/opnsense-sftp:latest \
|
||||
--build-arg VERSION=$VERSION \
|
||||
.
|
||||
docker push cr.jdbnet.co.uk/public/opnsense-sftp:$VERSION
|
||||
docker push cr.jdbnet.co.uk/public/opnsense-sftp:latest
|
||||
|
||||
- name: Create Gitea Release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.VERSION }}
|
||||
name: ${{ steps.get_version.outputs.VERSION }}
|
||||
body: ${{ steps.changelog.outputs.changelog }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -1,73 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
manifest-file: .release-please-manifest.json
|
||||
config-file: .release-please-config.json
|
||||
|
||||
- name: Checkout
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Read version
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/jdb-net/opnsense-sftp:${{ env.VERSION }}
|
||||
ghcr.io/jdb-net/opnsense-sftp:latest
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to Kubernetes
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
runs-on: [ k3s-lan-01 ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Apply manifests
|
||||
run: |
|
||||
sudo kubectl replace -f deployment.yml --grace-period=60 --force
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "simple",
|
||||
"version-file": "VERSION"
|
||||
}
|
||||
},
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
".": "1.1.2"
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.2](https://github.com/JDB-NET/opnsense-sftp/compare/v1.1.1...v1.1.2) (2025-11-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* :bug: k3s character limit ([2b91c19](https://github.com/JDB-NET/opnsense-sftp/commit/2b91c19afbf95f9192b43b46ebdc7816bb407db9))
|
||||
|
||||
## [1.1.1](https://github.com/JDB-NET/opnsense-sftp/compare/v1.1.0...v1.1.1) (2025-11-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* :bug: allow writing packages ([dbdeacd](https://github.com/JDB-NET/opnsense-sftp/commit/dbdeacdf7de133a0db4c44c13828458ff08a028a))
|
||||
|
||||
## [1.1.0](https://github.com/JDB-NET/opnsense-sftp/compare/v1.0.0...v1.1.0) (2025-11-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* :sparkles: initial commit ([07fc785](https://github.com/JDB-NET/opnsense-sftp/commit/07fc78592bc83e500ae0f3312d8a7bae9b0bf1f9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* :bug: public host ip ([6dd09cf](https://github.com/JDB-NET/opnsense-sftp/commit/6dd09cf147f7a20856af1cfa68390586f6acc2e5))
|
||||
* :bug: release please config ([89e1231](https://github.com/JDB-NET/opnsense-sftp/commit/89e12315fe69a64849068645048b01c871165532))
|
||||
+3
-5
@@ -1,11 +1,9 @@
|
||||
FROM python:3.13-slim
|
||||
LABEL org.opencontainers.image.vendor="JDB-NET"
|
||||
WORKDIR /app
|
||||
|
||||
# Build argument for version
|
||||
ARG VERSION=dev
|
||||
ENV APP_VERSION=${VERSION}
|
||||
|
||||
COPY . /app
|
||||
ARG VERSION=unknown
|
||||
ENV APP_VERSION=${VERSION}
|
||||
RUN pip install -r requirements.txt \
|
||||
&& apt-get update \
|
||||
&& apt-get install curl -y \
|
||||
|
||||
@@ -36,17 +36,15 @@ docker run -d \
|
||||
-e SFTP_PUBLIC_PORT=30222 \
|
||||
-v /path/to/keys:/app/keys \
|
||||
-v /path/to/backups:/app/backups \
|
||||
ghcr.io/jdb-net/opnsense-sftp:latest
|
||||
cr.jdbnet.co.uk/public/opnsense-sftp:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
opnsense-sftp:
|
||||
image: ghcr.io/jdb-net/opnsense-sftp:latest
|
||||
image: cr.jdbnet.co.uk/public/opnsense-sftp:latest
|
||||
container_name: opnsense-sftp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -7,8 +7,11 @@ from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import threading
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
import pyotp
|
||||
|
||||
from database import Database
|
||||
from ssh_keys import SSHKeyManager
|
||||
@@ -22,22 +25,8 @@ load_dotenv()
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Read version from VERSION file or environment
|
||||
def get_version():
|
||||
"""Get application version from VERSION file or environment variable."""
|
||||
# Check environment variable first (set during Docker build)
|
||||
env_version = os.getenv('APP_VERSION')
|
||||
if env_version and env_version != 'dev':
|
||||
return env_version
|
||||
|
||||
# Fall back to VERSION file
|
||||
try:
|
||||
version_path = Path(__file__).parent / 'VERSION'
|
||||
if version_path.exists():
|
||||
return version_path.read_text().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return 'dev'
|
||||
return os.getenv('APP_VERSION', 'dev')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.getenv('SECRET_KEY', 'change-this-secret-key-in-production')
|
||||
@@ -68,8 +57,11 @@ try:
|
||||
default_user = db.get_user_by_username('admin')
|
||||
if not default_user:
|
||||
default_password = os.getenv('ADMIN_PASSWORD', 'admin')
|
||||
db.create_user('admin', generate_password_hash(default_password))
|
||||
db.create_user('admin', generate_password_hash(default_password), is_admin=True)
|
||||
logger.warning("Created default admin user (password: 'admin' - CHANGE THIS!)")
|
||||
elif not default_user.get('is_admin'):
|
||||
db.update_user_admin(default_user['id'], True)
|
||||
logger.info("Updated default admin user with admin privileges")
|
||||
logger.info("Database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization failed: {e}")
|
||||
@@ -92,6 +84,34 @@ def login_required(f):
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin privileges."""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if not session.get('is_admin'):
|
||||
flash('Admin access required', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_current_user():
|
||||
"""Get current logged-in user from database."""
|
||||
user_id = session.get('user_id')
|
||||
if not user_id:
|
||||
return None
|
||||
return db.get_user_by_id(user_id)
|
||||
|
||||
|
||||
def _sign_in_user(user):
|
||||
"""Set session keys for authenticated user."""
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
session['is_admin'] = bool(user.get('is_admin'))
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Redirect to dashboard if logged in, otherwise to login."""
|
||||
@@ -103,6 +123,9 @@ def index():
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Login page."""
|
||||
if session.get('totp_pending_user_id'):
|
||||
return redirect(url_for('login_totp'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
@@ -113,8 +136,14 @@ def login():
|
||||
|
||||
user = db.get_user_by_username(username)
|
||||
if user and check_password_hash(user['password_hash'], password):
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
if user.get('totp_enabled'):
|
||||
# Step 1 complete: require second factor on separate page.
|
||||
session.clear()
|
||||
session['totp_pending_user_id'] = user['id']
|
||||
return redirect(url_for('login_totp'))
|
||||
|
||||
session.clear()
|
||||
_sign_in_user(user)
|
||||
return redirect(url_for('dashboard'))
|
||||
else:
|
||||
flash('Invalid username or password', 'error')
|
||||
@@ -122,6 +151,43 @@ def login():
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@app.route('/login/totp', methods=['GET', 'POST'])
|
||||
def login_totp():
|
||||
"""Second-factor login page for users with TOTP enabled."""
|
||||
pending_user_id = session.get('totp_pending_user_id')
|
||||
if not pending_user_id:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
user = db.get_user_by_id(pending_user_id)
|
||||
if not user or not user.get('totp_enabled'):
|
||||
session.pop('totp_pending_user_id', None)
|
||||
flash('TOTP session expired. Please log in again.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
|
||||
if not otp_code:
|
||||
flash('TOTP code is required', 'error')
|
||||
return render_template('login_totp.html', username=user['username'])
|
||||
|
||||
totp_secret = user.get('totp_secret')
|
||||
if not totp_secret:
|
||||
session.pop('totp_pending_user_id', None)
|
||||
flash('TOTP is enabled but no secret is configured. Contact an admin.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
is_valid = pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1)
|
||||
if not is_valid:
|
||||
flash('Invalid TOTP code', 'error')
|
||||
return render_template('login_totp.html', username=user['username'])
|
||||
|
||||
session.clear()
|
||||
_sign_in_user(user)
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
return render_template('login_totp.html', username=user['username'])
|
||||
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
"""Logout and clear session."""
|
||||
@@ -129,12 +195,204 @@ def logout():
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@app.route('/profile', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def profile():
|
||||
"""Manage current user's profile: username, password, and TOTP."""
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
session.clear()
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'update_username':
|
||||
new_username = (request.form.get('new_username') or '').strip()
|
||||
if not new_username:
|
||||
flash('Username is required', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
existing = db.get_user_by_username(new_username)
|
||||
if existing and existing['id'] != user['id']:
|
||||
flash('That username is already in use', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if db.update_user_username(user['id'], new_username):
|
||||
session['username'] = new_username
|
||||
flash('Username updated successfully', 'success')
|
||||
else:
|
||||
flash('Failed to update username', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if action == 'update_password':
|
||||
current_password = request.form.get('current_password') or ''
|
||||
new_password = request.form.get('new_password') or ''
|
||||
confirm_password = request.form.get('confirm_password') or ''
|
||||
|
||||
if not check_password_hash(user['password_hash'], current_password):
|
||||
flash('Current password is incorrect', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
if len(new_password) < 8:
|
||||
flash('New password must be at least 8 characters', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
if new_password != confirm_password:
|
||||
flash('New password and confirmation do not match', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if db.update_user_password(user['id'], generate_password_hash(new_password)):
|
||||
flash('Password updated successfully', 'success')
|
||||
else:
|
||||
flash('Failed to update password', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if action == 'generate_totp_secret':
|
||||
secret = pyotp.random_base32()
|
||||
if db.update_user_totp(user['id'], secret, False):
|
||||
flash('Generated a new TOTP secret. Verify a code to enable TOTP.', 'success')
|
||||
else:
|
||||
flash('Failed to generate TOTP secret', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if action == 'enable_totp':
|
||||
otp_code = (request.form.get('otp_code') or '').strip().replace(' ', '')
|
||||
fresh_user = db.get_user_by_id(user['id'])
|
||||
totp_secret = fresh_user.get('totp_secret') if fresh_user else None
|
||||
|
||||
if not totp_secret:
|
||||
flash('No TOTP secret found. Generate one first.', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if not pyotp.TOTP(totp_secret).verify(otp_code, valid_window=1):
|
||||
flash('Invalid TOTP code. Please try again.', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if db.update_user_totp(user['id'], totp_secret, True):
|
||||
flash('TOTP enabled for your account.', 'success')
|
||||
else:
|
||||
flash('Failed to enable TOTP', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
if action == 'disable_totp':
|
||||
if db.update_user_totp(user['id'], None, False):
|
||||
flash('TOTP disabled.', 'success')
|
||||
else:
|
||||
flash('Failed to disable TOTP', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
flash('Unknown action', 'error')
|
||||
return redirect(url_for('profile'))
|
||||
|
||||
user = get_current_user()
|
||||
if user.get('totp_secret'):
|
||||
totp_uri = pyotp.TOTP(user['totp_secret']).provisioning_uri(
|
||||
name=user['username'],
|
||||
issuer_name='OPNsense Backup Manager',
|
||||
)
|
||||
else:
|
||||
totp_uri = None
|
||||
return render_template('profile.html', user=user, totp_uri=totp_uri)
|
||||
|
||||
|
||||
@app.route('/users')
|
||||
@login_required
|
||||
@admin_required
|
||||
def users():
|
||||
"""List and manage users."""
|
||||
all_users = db.get_all_users()
|
||||
return render_template('users.html', users=all_users)
|
||||
|
||||
|
||||
@app.route('/users/create', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
"""Create a user account."""
|
||||
username = (request.form.get('username') or '').strip()
|
||||
password = request.form.get('password') or ''
|
||||
is_admin = bool(request.form.get('is_admin'))
|
||||
|
||||
if not username or not password:
|
||||
flash('Username and password are required', 'error')
|
||||
return redirect(url_for('users'))
|
||||
if len(password) < 8:
|
||||
flash('Password must be at least 8 characters', 'error')
|
||||
return redirect(url_for('users'))
|
||||
if db.get_user_by_username(username):
|
||||
flash('Username already exists', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
user_id = db.create_user(username, generate_password_hash(password), is_admin=is_admin)
|
||||
if user_id:
|
||||
flash('User created successfully', 'success')
|
||||
else:
|
||||
flash('Failed to create user', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
|
||||
@app.route('/users/<int:user_id>/toggle-admin', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def toggle_user_admin(user_id):
|
||||
"""Toggle admin role for a user."""
|
||||
target_user = db.get_user_by_id(user_id)
|
||||
if not target_user:
|
||||
flash('User not found', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
all_users = db.get_all_users()
|
||||
admin_count = sum(1 for u in all_users if u.get('is_admin'))
|
||||
|
||||
new_is_admin = not bool(target_user.get('is_admin'))
|
||||
if not new_is_admin and admin_count <= 1:
|
||||
flash('Cannot remove admin role from the last admin user', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
if target_user['id'] == session.get('user_id') and not new_is_admin:
|
||||
flash('You cannot remove your own admin role', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
if db.update_user_admin(user_id, new_is_admin):
|
||||
flash('User admin role updated', 'success')
|
||||
else:
|
||||
flash('Failed to update user admin role', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
|
||||
@app.route('/users/<int:user_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
"""Delete a user account."""
|
||||
if user_id == session.get('user_id'):
|
||||
flash('You cannot delete your own account', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
target_user = db.get_user_by_id(user_id)
|
||||
if not target_user:
|
||||
flash('User not found', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
all_users = db.get_all_users()
|
||||
admin_count = sum(1 for u in all_users if u.get('is_admin'))
|
||||
if target_user.get('is_admin') and admin_count <= 1:
|
||||
flash('Cannot delete the last admin user', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
if db.delete_user(user_id):
|
||||
flash('User deleted successfully', 'success')
|
||||
else:
|
||||
flash('Failed to delete user', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
|
||||
@app.route('/dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Main dashboard."""
|
||||
instances = db.get_all_instances()
|
||||
all_backups = db.get_all_backups()
|
||||
total_backup_size = sum((backup.get('file_size') or 0) for backup in all_backups)
|
||||
|
||||
# Add instance info to each backup
|
||||
for backup in all_backups:
|
||||
@@ -143,7 +401,13 @@ def dashboard():
|
||||
backup['instance_name'] = instance['name']
|
||||
backup['instance_identifier'] = instance['identifier']
|
||||
|
||||
return render_template('dashboard.html', instances=instances, backups=all_backups, sftp_server=sftp_server)
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
instances=instances,
|
||||
backups=all_backups,
|
||||
sftp_server=sftp_server,
|
||||
total_backup_size=total_backup_size
|
||||
)
|
||||
|
||||
|
||||
@app.route('/instances')
|
||||
@@ -246,17 +510,74 @@ def instance_detail(instance_id):
|
||||
@app.route('/backups')
|
||||
@login_required
|
||||
def backups():
|
||||
"""List all backups."""
|
||||
"""List all backups with pagination and filtering."""
|
||||
all_backups = db.get_all_backups()
|
||||
|
||||
# Add instance info to each backup
|
||||
instances_map = {}
|
||||
for backup in all_backups:
|
||||
instance = db.get_instance_by_id(backup['instance_id'])
|
||||
if backup['instance_id'] not in instances_map:
|
||||
instance = db.get_instance_by_id(backup['instance_id'])
|
||||
instances_map[backup['instance_id']] = instance
|
||||
|
||||
instance = instances_map.get(backup['instance_id'])
|
||||
if instance:
|
||||
backup['instance_name'] = instance['name']
|
||||
backup['instance_identifier'] = instance['identifier']
|
||||
|
||||
return render_template('backups.html', backups=all_backups)
|
||||
# Get unique instances for filter dropdown
|
||||
all_instances = []
|
||||
seen_ids = set()
|
||||
for backup in all_backups:
|
||||
instance_id = backup['instance_id']
|
||||
if instance_id not in seen_ids and backup.get('instance_name'):
|
||||
all_instances.append({
|
||||
'id': instance_id,
|
||||
'name': backup['instance_name'],
|
||||
'identifier': backup['instance_identifier']
|
||||
})
|
||||
seen_ids.add(instance_id)
|
||||
|
||||
# Sort instances by name
|
||||
all_instances.sort(key=lambda x: x['name'])
|
||||
|
||||
# Get filter parameter from query string
|
||||
filter_instance_id = request.args.get('instance_id', type=int)
|
||||
|
||||
# Filter backups if instance filter is applied
|
||||
if filter_instance_id:
|
||||
filtered_backups = [b for b in all_backups if b['instance_id'] == filter_instance_id]
|
||||
else:
|
||||
filtered_backups = all_backups
|
||||
|
||||
# Sort by upload date descending (newest first)
|
||||
filtered_backups.sort(key=lambda x: x['uploaded_at'] or datetime.min, reverse=True)
|
||||
|
||||
# Pagination
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 10
|
||||
total_backups = len(filtered_backups)
|
||||
total_pages = (total_backups + per_page - 1) // per_page
|
||||
|
||||
# Ensure page is valid
|
||||
if page < 1:
|
||||
page = 1
|
||||
elif page > total_pages and total_pages > 0:
|
||||
page = total_pages
|
||||
|
||||
# Get backups for current page
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
paginated_backups = filtered_backups[start_idx:end_idx]
|
||||
|
||||
return render_template('backups.html',
|
||||
backups=paginated_backups,
|
||||
all_instances=all_instances,
|
||||
current_page=page,
|
||||
total_pages=total_pages,
|
||||
total_backups=total_backups,
|
||||
filter_instance_id=filter_instance_id,
|
||||
per_page=per_page)
|
||||
|
||||
|
||||
@app.route('/backups/<int:backup_id>/download')
|
||||
@@ -342,6 +663,291 @@ def delete_backup(backup_id):
|
||||
return redirect(url_for('backups'))
|
||||
|
||||
|
||||
def prune_backups(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_instance_id: int | None,
|
||||
keep_days: int | None,
|
||||
keep_count: int | None,
|
||||
) -> dict:
|
||||
"""
|
||||
Prune backups by retention rules.
|
||||
|
||||
- keep_days: delete backups older than (now - keep_days)
|
||||
- keep_count: keep N newest backups per instance
|
||||
"""
|
||||
if (keep_days is None and keep_count is None) or (keep_days is not None and keep_count is not None):
|
||||
raise ValueError("Exactly one of keep_days or keep_count must be provided.")
|
||||
|
||||
if scope_type not in {"all", "instance"}:
|
||||
raise ValueError("scope_type must be 'all' or 'instance'.")
|
||||
|
||||
instances = []
|
||||
if scope_type == "instance":
|
||||
if not scope_instance_id:
|
||||
raise ValueError("scope_instance_id must be provided when scope_type is 'instance'.")
|
||||
instance = db.get_instance_by_id(scope_instance_id)
|
||||
if not instance:
|
||||
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 1}
|
||||
instances = [instance]
|
||||
else:
|
||||
instances = db.get_all_instances()
|
||||
|
||||
now = datetime.now()
|
||||
cutoff = (now - timedelta(days=keep_days)) if keep_days is not None else None
|
||||
|
||||
backups_marked_for_deletion: list[dict] = []
|
||||
for instance in instances:
|
||||
backups = db.get_backups_for_instance(instance["id"])
|
||||
|
||||
if keep_days is not None:
|
||||
# db query orders by uploaded_at DESC, but for "older than" it's fine to just filter.
|
||||
for backup in backups:
|
||||
uploaded_at = backup.get("uploaded_at")
|
||||
if uploaded_at is None or uploaded_at < cutoff:
|
||||
backups_marked_for_deletion.append(backup)
|
||||
else:
|
||||
# backups are already sorted DESC; keep first N, prune the rest.
|
||||
backups_marked_for_deletion.extend(backups[keep_count:])
|
||||
|
||||
if not backups_marked_for_deletion:
|
||||
return {"deleted_backups": 0, "deleted_files": 0, "skipped_files": 0, "errors": 0}
|
||||
|
||||
deleted_files = 0
|
||||
skipped_files = 0
|
||||
db_ids_to_delete: list[int] = []
|
||||
|
||||
for backup in backups_marked_for_deletion:
|
||||
file_path = Path(backup["file_path"])
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
deleted_files += 1
|
||||
else:
|
||||
# Stale DB rows are still safe to remove.
|
||||
skipped_files += 1
|
||||
db_ids_to_delete.append(backup["id"])
|
||||
except Exception as e:
|
||||
skipped_files += 1
|
||||
logger.error(f"Failed deleting backup file {file_path}: {e}", exc_info=True)
|
||||
|
||||
deleted_backups = db.delete_backups_by_ids(db_ids_to_delete)
|
||||
return {
|
||||
"deleted_backups": deleted_backups,
|
||||
"deleted_files": deleted_files,
|
||||
"skipped_files": skipped_files,
|
||||
"errors": 0,
|
||||
}
|
||||
|
||||
|
||||
@app.route("/backups/prune", methods=["GET"])
|
||||
@login_required
|
||||
def prune_page():
|
||||
"""Backup pruning manual runner + automated retention settings."""
|
||||
instances = db.get_all_instances()
|
||||
prune_settings = db.get_backup_prune_settings()
|
||||
return render_template("prune.html", instances=instances, prune_settings=prune_settings)
|
||||
|
||||
|
||||
@app.route("/backups/prune/run", methods=["POST"])
|
||||
@login_required
|
||||
def prune_run():
|
||||
"""Run a manual prune based on form criteria."""
|
||||
if not request.form.get("confirm_prune"):
|
||||
flash("Confirmation required. Check the confirmation box to prune backups.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
scope_type = request.form.get("scope_type", "all")
|
||||
scope_instance_id = request.form.get("scope_instance_id", type=int)
|
||||
|
||||
keep_mode = request.form.get("keep_mode")
|
||||
keep_days = None
|
||||
keep_count = None
|
||||
|
||||
if keep_mode == "days":
|
||||
keep_days = request.form.get("keep_days", type=int)
|
||||
elif keep_mode == "count":
|
||||
keep_count = request.form.get("keep_count", type=int)
|
||||
else:
|
||||
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
if scope_type == "instance" and not scope_instance_id:
|
||||
flash("Please select an instance when pruning a single instance.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
if keep_days is not None and keep_days < 1:
|
||||
flash("Keep days must be >= 1.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
if keep_count is not None and keep_count < 1:
|
||||
flash("Keep count must be >= 1.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
try:
|
||||
result = prune_backups(
|
||||
scope_type=scope_type,
|
||||
scope_instance_id=scope_instance_id,
|
||||
keep_days=keep_days,
|
||||
keep_count=keep_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Manual prune failed: {e}", exc_info=True)
|
||||
flash(f"Prune failed: {e}", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
flash(
|
||||
f"Prune complete. Deleted {result['deleted_backups']} backup records. "
|
||||
f"Deleted {result['deleted_files']} files.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
|
||||
@app.route("/backups/prune/settings", methods=["POST"])
|
||||
@login_required
|
||||
def prune_settings():
|
||||
"""Update automated pruning settings (and optionally run immediately)."""
|
||||
enabled = bool(request.form.get("enabled"))
|
||||
|
||||
scope_type = request.form.get("scope_type", "all")
|
||||
scope_instance_id = request.form.get("scope_instance_id", type=int)
|
||||
if scope_type == "all":
|
||||
scope_instance_id = None
|
||||
|
||||
keep_mode = request.form.get("keep_mode")
|
||||
keep_days = None
|
||||
keep_count = None
|
||||
|
||||
if keep_mode == "days":
|
||||
keep_days = request.form.get("keep_days", type=int)
|
||||
elif keep_mode == "count":
|
||||
keep_count = request.form.get("keep_count", type=int)
|
||||
else:
|
||||
flash("Invalid keep mode. Choose either 'Days' or 'Count'.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
interval_hours = request.form.get("interval_hours", type=int) or 24
|
||||
if interval_hours < 1:
|
||||
interval_hours = 24
|
||||
interval_seconds = interval_hours * 3600
|
||||
|
||||
if enabled and scope_type == "instance" and not scope_instance_id:
|
||||
flash("Please select an instance for automated pruning.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
if keep_days is not None and keep_days < 1:
|
||||
flash("Keep days must be >= 1.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
if keep_count is not None and keep_count < 1:
|
||||
flash("Keep count must be >= 1.", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
db.upsert_backup_prune_settings(
|
||||
enabled=enabled,
|
||||
scope_type=scope_type,
|
||||
scope_instance_id=scope_instance_id,
|
||||
keep_days=keep_days if enabled else None,
|
||||
keep_count=keep_count if enabled else None,
|
||||
interval_seconds=interval_seconds,
|
||||
)
|
||||
|
||||
# Optional: run immediately.
|
||||
if request.form.get("action") == "run_now" and enabled:
|
||||
try:
|
||||
prune_backups(
|
||||
scope_type=scope_type,
|
||||
scope_instance_id=scope_instance_id,
|
||||
keep_days=keep_days,
|
||||
keep_count=keep_count,
|
||||
)
|
||||
db.set_backup_prune_last_run_at(datetime.now())
|
||||
except Exception as e:
|
||||
logger.error(f"Run-now prune failed: {e}", exc_info=True)
|
||||
flash(f"Saved settings, but run now failed: {e}", "error")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
flash("Saved settings and ran prune now.", "success")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
flash("Automated prune settings saved.", "success")
|
||||
return redirect(url_for("prune_page"))
|
||||
|
||||
|
||||
def _auto_prune_loop():
|
||||
"""Background worker that periodically prunes backups based on stored settings."""
|
||||
while True:
|
||||
try:
|
||||
settings = db.get_backup_prune_settings()
|
||||
enabled = bool(settings.get("enabled"))
|
||||
interval_seconds = int(settings.get("interval_seconds") or 86400)
|
||||
|
||||
if enabled:
|
||||
keep_days = settings.get("keep_days")
|
||||
keep_count = settings.get("keep_count")
|
||||
scope_type = settings.get("scope_type") or "all"
|
||||
scope_instance_id = settings.get("scope_instance_id")
|
||||
|
||||
# Determine keep mode (validated by settings form, but be defensive).
|
||||
if keep_days is not None and keep_days >= 1 and keep_count is None:
|
||||
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
|
||||
elif keep_count is not None and keep_count >= 1 and keep_days is None:
|
||||
keep_kwargs = {"keep_days": None, "keep_count": int(keep_count)}
|
||||
elif keep_days is not None and keep_days >= 1 and keep_count is not None and keep_count >= 1:
|
||||
# Prefer days if both are set.
|
||||
keep_kwargs = {"keep_days": int(keep_days), "keep_count": None}
|
||||
else:
|
||||
keep_kwargs = None
|
||||
|
||||
last_run_at = settings.get("last_run_at")
|
||||
should_run = last_run_at is None
|
||||
if last_run_at is not None:
|
||||
try:
|
||||
should_run = (datetime.now() - last_run_at).total_seconds() >= interval_seconds
|
||||
except Exception:
|
||||
should_run = True
|
||||
|
||||
if keep_kwargs and should_run:
|
||||
prune_backups(
|
||||
scope_type=scope_type,
|
||||
scope_instance_id=scope_instance_id,
|
||||
keep_days=keep_kwargs["keep_days"],
|
||||
keep_count=keep_kwargs["keep_count"],
|
||||
)
|
||||
db.set_backup_prune_last_run_at(datetime.now())
|
||||
logger.info(
|
||||
f"Auto prune ran. scope_type={scope_type}, "
|
||||
f"scope_instance_id={scope_instance_id}"
|
||||
)
|
||||
|
||||
time.sleep(interval_seconds)
|
||||
else:
|
||||
time.sleep(3600)
|
||||
except Exception as e:
|
||||
logger.error(f"Auto prune loop error: {e}", exc_info=True)
|
||||
time.sleep(300)
|
||||
|
||||
|
||||
@app.route('/api/backups/latest')
|
||||
def api_latest_backups():
|
||||
"""API endpoint to get the latest backup date and time for each instance."""
|
||||
latest_backups = db.get_latest_backup_per_instance()
|
||||
|
||||
# Format the response
|
||||
result = []
|
||||
for item in latest_backups:
|
||||
result.append({
|
||||
'instance_id': item['instance_id'],
|
||||
'instance_name': item['instance_name'],
|
||||
'latest_backup': item['latest_backup'].isoformat() if item['latest_backup'] else None
|
||||
})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# Start background auto-prune worker.
|
||||
threading.Thread(target=_auto_prune_loop, daemon=True).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
|
||||
+266
-3
@@ -5,6 +5,7 @@ import mysql.connector
|
||||
from mysql.connector import Error
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from logger_config import get_logger
|
||||
@@ -57,6 +58,19 @@ class Database:
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Backward-compatible user auth columns.
|
||||
cursor.execute("SHOW COLUMNS FROM users LIKE 'is_admin'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE")
|
||||
|
||||
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_secret'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NULL")
|
||||
|
||||
cursor.execute("SHOW COLUMNS FROM users LIKE 'totp_enabled'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE")
|
||||
|
||||
# Create opnsense_instances table
|
||||
cursor.execute("""
|
||||
@@ -96,6 +110,32 @@ class Database:
|
||||
FOREIGN KEY (instance_id) REFERENCES opnsense_instances(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Create backup pruning settings table (single policy row).
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_prune_settings (
|
||||
id INT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
scope_type VARCHAR(10) NOT NULL DEFAULT 'all', /* 'all' or 'instance' */
|
||||
scope_instance_id INT NULL,
|
||||
keep_days INT NULL,
|
||||
keep_count INT NULL,
|
||||
interval_seconds INT NOT NULL DEFAULT 86400,
|
||||
last_run_at TIMESTAMP NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (scope_instance_id) REFERENCES opnsense_instances(id) ON DELETE SET NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Ensure we have exactly one settings row.
|
||||
cursor.execute("""
|
||||
INSERT INTO backup_prune_settings
|
||||
(id, enabled, scope_type, scope_instance_id, keep_days, keep_count, interval_seconds, last_run_at)
|
||||
VALUES
|
||||
(1, FALSE, 'all', NULL, NULL, NULL, 86400, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
id = id
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
@@ -104,14 +144,14 @@ class Database:
|
||||
logger.error(f"Error initializing database: {e}")
|
||||
raise
|
||||
|
||||
def create_user(self, username: str, password_hash: str) -> Optional[int]:
|
||||
def create_user(self, username: str, password_hash: str, is_admin: bool = False) -> Optional[int]:
|
||||
"""Create a new user."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (%s, %s)",
|
||||
(username, password_hash)
|
||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (%s, %s, %s)",
|
||||
(username, password_hash, is_admin)
|
||||
)
|
||||
conn.commit()
|
||||
user_id = cursor.lastrowid
|
||||
@@ -133,6 +173,106 @@ class Database:
|
||||
except Error as e:
|
||||
logger.error(f"Error getting user: {e}")
|
||||
return None
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get user by ID."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
cursor.close()
|
||||
return user
|
||||
except Error as e:
|
||||
logger.error(f"Error getting user by id: {e}")
|
||||
return None
|
||||
|
||||
def get_all_users(self) -> List[Dict[str, Any]]:
|
||||
"""Get all users."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, username, is_admin, totp_enabled, created_at
|
||||
FROM users
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
)
|
||||
users = cursor.fetchall()
|
||||
cursor.close()
|
||||
return users
|
||||
except Error as e:
|
||||
logger.error(f"Error getting users: {e}")
|
||||
return []
|
||||
|
||||
def update_user_username(self, user_id: int, username: str) -> bool:
|
||||
"""Update username for a user."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET username = %s WHERE id = %s", (username, user_id))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
except Error as e:
|
||||
logger.error(f"Error updating username: {e}")
|
||||
return False
|
||||
|
||||
def update_user_password(self, user_id: int, password_hash: str) -> bool:
|
||||
"""Update password hash for a user."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, user_id))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
except Error as e:
|
||||
logger.error(f"Error updating password hash: {e}")
|
||||
return False
|
||||
|
||||
def update_user_totp(self, user_id: int, totp_secret: Optional[str], totp_enabled: bool) -> bool:
|
||||
"""Update TOTP settings for a user."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE users SET totp_secret = %s, totp_enabled = %s WHERE id = %s",
|
||||
(totp_secret, totp_enabled, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
except Error as e:
|
||||
logger.error(f"Error updating TOTP settings: {e}")
|
||||
return False
|
||||
|
||||
def update_user_admin(self, user_id: int, is_admin: bool) -> bool:
|
||||
"""Update admin flag for a user."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE users SET is_admin = %s WHERE id = %s", (is_admin, user_id))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
except Error as e:
|
||||
logger.error(f"Error updating user admin flag: {e}")
|
||||
return False
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
"""Delete user by ID."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return True
|
||||
except Error as e:
|
||||
logger.error(f"Error deleting user: {e}")
|
||||
return False
|
||||
|
||||
def create_instance(self, name: str, identifier: str, ssh_key_id: str, description: str = "") -> Optional[int]:
|
||||
"""Create a new OPNsense instance."""
|
||||
@@ -279,4 +419,127 @@ class Database:
|
||||
except Error as e:
|
||||
logger.error(f"Error getting instance by ID: {e}")
|
||||
return None
|
||||
|
||||
def get_latest_backup_per_instance(self) -> List[Dict[str, Any]]:
|
||||
"""Get the latest backup date and time for each instance."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
o.id as instance_id,
|
||||
o.name as instance_name,
|
||||
o.identifier as instance_identifier,
|
||||
MAX(b.uploaded_at) as latest_backup
|
||||
FROM opnsense_instances o
|
||||
LEFT JOIN backups b ON o.id = b.instance_id
|
||||
GROUP BY o.id, o.name, o.identifier
|
||||
ORDER BY o.name
|
||||
""")
|
||||
results = cursor.fetchall()
|
||||
cursor.close()
|
||||
return results
|
||||
except Error as e:
|
||||
logger.error(f"Error getting latest backup per instance: {e}")
|
||||
return []
|
||||
|
||||
def get_backup_prune_settings(self) -> Dict[str, Any]:
|
||||
"""Get automated backup prune settings (single row)."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM backup_prune_settings WHERE id = %s", (1,))
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
if row:
|
||||
return row
|
||||
except Error as e:
|
||||
logger.error(f"Error getting backup prune settings: {e}")
|
||||
# Safe defaults if table/row doesn't exist yet.
|
||||
return {
|
||||
"id": 1,
|
||||
"enabled": False,
|
||||
"scope_type": "all",
|
||||
"scope_instance_id": None,
|
||||
"keep_days": None,
|
||||
"keep_count": None,
|
||||
"interval_seconds": 86400,
|
||||
"last_run_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
def upsert_backup_prune_settings(
|
||||
self,
|
||||
enabled: bool,
|
||||
scope_type: str,
|
||||
scope_instance_id: Optional[int],
|
||||
keep_days: Optional[int],
|
||||
keep_count: Optional[int],
|
||||
interval_seconds: int,
|
||||
) -> None:
|
||||
"""Upsert automated backup prune settings (single row)."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO backup_prune_settings
|
||||
(id, enabled, scope_type, scope_instance_id, keep_days, keep_count, interval_seconds)
|
||||
VALUES
|
||||
(1, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
scope_type = VALUES(scope_type),
|
||||
scope_instance_id = VALUES(scope_instance_id),
|
||||
keep_days = VALUES(keep_days),
|
||||
keep_count = VALUES(keep_count),
|
||||
interval_seconds = VALUES(interval_seconds)
|
||||
""",
|
||||
(
|
||||
bool(enabled),
|
||||
scope_type,
|
||||
scope_instance_id,
|
||||
keep_days,
|
||||
keep_count,
|
||||
interval_seconds,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
except Error as e:
|
||||
logger.error(f"Error updating backup prune settings: {e}")
|
||||
|
||||
def set_backup_prune_last_run_at(self, last_run_at: datetime) -> None:
|
||||
"""Update last_run_at after a prune run."""
|
||||
try:
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE backup_prune_settings SET last_run_at = %s WHERE id = %s",
|
||||
(last_run_at, 1),
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
except Error as e:
|
||||
logger.error(f"Error updating backup prune last_run_at: {e}")
|
||||
|
||||
def delete_backups_by_ids(self, backup_ids: List[int]) -> int:
|
||||
"""Delete backup records by IDs (returns number of deleted rows)."""
|
||||
if not backup_ids:
|
||||
return 0
|
||||
try:
|
||||
placeholders = ", ".join(["%s"] * len(backup_ids))
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"DELETE FROM backups WHERE id IN ({placeholders})",
|
||||
tuple(backup_ids),
|
||||
)
|
||||
affected = cursor.rowcount or 0
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return affected
|
||||
except Error as e:
|
||||
logger.error(f"Error deleting backups by ids: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
+7
-7
@@ -15,7 +15,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: opnsense-sftp
|
||||
image: ghcr.io/jdb-net/opnsense-sftp:latest
|
||||
image: cr.jdbnet.co.uk/public/opnsense-sftp:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
@@ -24,7 +24,7 @@ spec:
|
||||
name: "opnsense-sftp"
|
||||
env:
|
||||
- name: DB_HOST
|
||||
value: "10.10.2.27"
|
||||
value: "10.10.25.4"
|
||||
- name: DB_PORT
|
||||
value: "3306"
|
||||
- name: DB_NAME
|
||||
@@ -38,7 +38,7 @@ spec:
|
||||
- name: ADMIN_PASSWORD
|
||||
value: "CVk7QKIB3MjZ8mt6MxES"
|
||||
- name: SFTP_PUBLIC_HOST
|
||||
value: "10.10.2.29"
|
||||
value: "10.10.25.8"
|
||||
- name: SFTP_PUBLIC_PORT
|
||||
value: "30222"
|
||||
volumeMounts:
|
||||
@@ -49,12 +49,12 @@ spec:
|
||||
volumes:
|
||||
- name: keys-volume
|
||||
nfs:
|
||||
server: 10.10.2.5
|
||||
path: /srv/Backups/OPNsense/keys
|
||||
server: 10.10.25.2
|
||||
path: /srv/k3s/opnsense/keys
|
||||
- name: backups-volume
|
||||
nfs:
|
||||
server: 10.10.2.5
|
||||
path: /srv/Backups/OPNsense/backups
|
||||
server: 10.10.25.2
|
||||
path: /srv/k3s/opnsense/backups
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
+2
-1
@@ -4,4 +4,5 @@ paramiko
|
||||
mysql-connector-python
|
||||
cryptography
|
||||
werkzeug
|
||||
python-dotenv
|
||||
python-dotenv
|
||||
pyotp
|
||||
+92
-2
@@ -9,9 +9,40 @@
|
||||
<p class="text-neutral-400">View and download your backup files</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||
<h2 class="text-lg font-bold text-neutral-100 mb-4">Filters</h2>
|
||||
<form method="get" class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<label for="instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance</label>
|
||||
<select id="instance_id" name="instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
<option value="">All Instances</option>
|
||||
{% for instance in all_instances %}
|
||||
<option value="{{ instance.id }}" {% if filter_instance_id == instance.id %}selected{% endif %}>
|
||||
{{ instance.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2 sm:self-end">
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Apply Filter
|
||||
</button>
|
||||
{% if filter_instance_id %}
|
||||
<a href="{{ url_for('backups') }}" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors">
|
||||
Clear Filter
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-neutral-700">
|
||||
<div class="px-6 py-4 border-b border-neutral-700 flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-neutral-100">Backup Files</h2>
|
||||
{% if total_backups > 0 %}
|
||||
<span class="text-sm text-neutral-400">{{ total_backups }} backup{% if total_backups != 1 %}s{% endif %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
@@ -51,7 +82,11 @@
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-8 text-center text-neutral-400">
|
||||
No backups found. Configure your OPNsense instances to start receiving backups.
|
||||
{% if filter_instance_id %}
|
||||
No backups found for the selected instance.
|
||||
{% else %}
|
||||
No backups found. Configure your OPNsense instances to start receiving backups.
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@@ -59,6 +94,61 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
|
||||
<div class="text-sm text-neutral-400">
|
||||
Page <span class="font-bold text-neutral-100">{{ current_page }}</span> of <span class="font-bold text-neutral-100">{{ total_pages }}</span>
|
||||
({{ (current_page - 1) * per_page + 1 }}-{{ [current_page * per_page, total_backups]|min }} of {{ total_backups }} backups)
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
{% if current_page > 1 %}
|
||||
<a href="{{ url_for('backups', page=1, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
|
||||
First
|
||||
</a>
|
||||
<a href="{{ url_for('backups', page=current_page - 1, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page Numbers -->
|
||||
{% set start_page = [current_page - 2, 1]|max %}
|
||||
{% set end_page = [current_page + 2, total_pages]|min %}
|
||||
|
||||
{% if start_page > 1 %}
|
||||
<span class="px-3 py-2 text-neutral-400">...</span>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in range(start_page, end_page + 1) %}
|
||||
{% if page_num == current_page %}
|
||||
<button class="px-3 py-2 bg-orange-600 text-white rounded-md text-sm font-medium">
|
||||
{{ page_num }}
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{{ url_for('backups', page=page_num, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if end_page < total_pages %}
|
||||
<span class="px-3 py-2 text-neutral-400">...</span>
|
||||
{% endif %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="{{ url_for('backups', page=current_page + 1, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
|
||||
Next
|
||||
</a>
|
||||
<a href="{{ url_for('backups', page=total_pages, instance_id=filter_instance_id or '') }}" class="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm transition-colors">
|
||||
Last
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
+69
-6
@@ -7,18 +7,19 @@
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='opnsense.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
||||
</head>
|
||||
<body class="bg-neutral-900 text-neutral-100 min-h-screen">
|
||||
<body class="bg-neutral-900 text-neutral-100 min-h-screen flex flex-col">
|
||||
<nav class="bg-neutral-800 border-b border-neutral-700">
|
||||
<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 items-center">
|
||||
<a href="{{ url_for('dashboard') }}" class="flex items-center space-x-4">
|
||||
<img src="{{ url_for('static', filename='opnsense.png') }}" alt="OPNsense" class="h-8 w-8">
|
||||
<span class="text-neutral-400">Backup Manager</span>
|
||||
<span class="text-neutral-400 hidden sm:inline">Backup Manager</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if session.user_id %}
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<a href="{{ url_for('dashboard') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Dashboard
|
||||
</a>
|
||||
@@ -28,18 +29,68 @@
|
||||
<a href="{{ url_for('backups') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Backups
|
||||
</a>
|
||||
<a href="{{ url_for('prune_page') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Prune
|
||||
</a>
|
||||
{% if session.is_admin %}
|
||||
<a href="{{ url_for('users') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Users
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="text-neutral-400">|</span>
|
||||
<span class="text-neutral-300">{{ session.username }}</span>
|
||||
<a href="{{ url_for('profile') }}" class="text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
{{ session.username }}
|
||||
</a>
|
||||
<a href="{{ url_for('logout') }}" class="bg-neutral-700 hover:bg-neutral-600 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<div class="md:hidden flex items-center">
|
||||
<button id="mobile-menu-btn" class="inline-flex items-center justify-center p-2 rounded-md text-neutral-300 hover:text-orange-500 hover:bg-neutral-700 focus:outline-none transition-colors">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu -->
|
||||
{% if session.user_id %}
|
||||
<div id="mobile-menu" class="hidden md:hidden pb-4 space-y-1">
|
||||
<a href="{{ url_for('dashboard') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('instances') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||
Instances
|
||||
</a>
|
||||
<a href="{{ url_for('backups') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||
Backups
|
||||
</a>
|
||||
<a href="{{ url_for('prune_page') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||
Prune
|
||||
</a>
|
||||
{% if session.is_admin %}
|
||||
<a href="{{ url_for('users') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||
Users
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="border-t border-neutral-700 pt-2 mt-2">
|
||||
<a href="{{ url_for('profile') }}" class="block px-3 py-2 text-sm text-neutral-400 hover:text-orange-500 rounded-md transition-colors hover:bg-neutral-700">
|
||||
{{ session.username }}
|
||||
</a>
|
||||
<a href="{{ url_for('logout') }}" class="block text-neutral-300 hover:text-orange-500 px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-neutral-700">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<main class="flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-2">
|
||||
@@ -60,11 +111,23 @@
|
||||
<p class="text-center text-neutral-400 text-sm">
|
||||
OPNsense Backup Manager
|
||||
{% if version %}
|
||||
<span class="text-neutral-500">v{{ version }}</span>
|
||||
<span class="text-neutral-500">{{ version }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Mobile menu toggle
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
if (mobileMenuBtn && mobileMenu) {
|
||||
mobileMenuBtn.addEventListener('click', function() {
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -39,6 +39,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-neutral-400 text-sm font-medium">Total Backup Size</p>
|
||||
<p class="text-3xl font-bold text-orange-500 mt-2">
|
||||
{% if total_backup_size >= 1024 * 1024 * 1024 %}
|
||||
{{ "%.2f"|format(total_backup_size / 1024 / 1024 / 1024) }} GB
|
||||
{% elif total_backup_size >= 1024 * 1024 %}
|
||||
{{ "%.2f"|format(total_backup_size / 1024 / 1024) }} MB
|
||||
{% elif total_backup_size >= 1024 %}
|
||||
{{ "%.2f"|format(total_backup_size / 1024) }} KB
|
||||
{% else %}
|
||||
{{ total_backup_size }} B
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-orange-500/20 rounded-lg">
|
||||
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16m-7 5h7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg p-6 border border-neutral-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -42,18 +42,11 @@
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
|
||||
>
|
||||
class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 hover:cursor-pointer">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 p-4 bg-neutral-700 rounded-md">
|
||||
<p class="text-xs text-neutral-400">
|
||||
Default credentials: <code class="text-orange-400">admin / admin</code>
|
||||
<br>Please change these after first login!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Two-Factor Verification - OPNsense Backup Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-[calc(100vh-12rem)] flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-neutral-800 rounded-lg shadow-lg p-8 border border-neutral-700">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-orange-500 mb-2">Two-Factor Verification</h1>
|
||||
<p class="text-neutral-400">Enter the code from your authenticator app.</p>
|
||||
{% if username %}
|
||||
<p class="text-sm text-neutral-500 mt-2">Signing in as <span class="text-neutral-300">{{ username }}</span></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('login_totp') }}" class="space-y-6">
|
||||
<div>
|
||||
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">
|
||||
TOTP Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="otp_code"
|
||||
name="otp_code"
|
||||
required
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="6-digit code"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-orange-500 hover:bg-orange-600 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200 hover:cursor-pointer">
|
||||
Verify and Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="{{ url_for('logout') }}" class="text-sm text-neutral-400 hover:text-orange-500 transition-colors">
|
||||
Cancel and return to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile - OPNsense Backup Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Profile</h1>
|
||||
<p class="text-neutral-400">Manage your username, password, and multi-factor authentication.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Username</h2>
|
||||
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
|
||||
<input type="hidden" name="action" value="update_username">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Current Username</label>
|
||||
<input type="text" disabled value="{{ user.username }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-400">
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_username" class="block text-sm font-medium text-neutral-300 mb-2">New Username</label>
|
||||
<input id="new_username" name="new_username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Username</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||
<h2 class="text-xl font-bold text-neutral-100 mb-4">Change Password</h2>
|
||||
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
|
||||
<input type="hidden" name="action" value="update_password">
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-neutral-300 mb-2">Current Password</label>
|
||||
<input id="current_password" name="current_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-neutral-300 mb-2">New Password</label>
|
||||
<input id="new_password" name="new_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm_password" class="block text-sm font-medium text-neutral-300 mb-2">Confirm New Password</label>
|
||||
<input id="confirm_password" name="confirm_password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">Update Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6 space-y-5">
|
||||
<h2 class="text-xl font-bold text-neutral-100">Two-Factor Authentication (TOTP)</h2>
|
||||
<p class="text-neutral-400 text-sm">
|
||||
Use an authenticator app (Aegis, Authy, 1Password, Google Authenticator, etc.) to secure your account.
|
||||
</p>
|
||||
|
||||
{% if user.totp_enabled %}
|
||||
<div class="bg-green-900/30 border border-green-700 rounded-md p-4 text-sm text-green-300">
|
||||
TOTP is currently enabled for your account.
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('profile') }}">
|
||||
<input type="hidden" name="action" value="disable_totp">
|
||||
<button type="submit" class="px-4 py-2 bg-red-700 hover:bg-red-600 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Disable TOTP
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
{% if not user.totp_secret %}
|
||||
<form method="post" action="{{ url_for('profile') }}">
|
||||
<input type="hidden" name="action" value="generate_totp_secret">
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Generate TOTP Secret
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="bg-neutral-900 border border-neutral-700 rounded-md p-4 space-y-2">
|
||||
<p class="text-sm text-neutral-300"><strong>Scan QR Code:</strong></p>
|
||||
<div id="totp-qrcode" class="bg-white p-2 rounded-md inline-block"></div>
|
||||
<p class="text-sm text-neutral-300"><strong>Secret:</strong> <code class="text-orange-400">{{ user.totp_secret }}</code></p>
|
||||
<p class="text-sm text-neutral-300"><strong>OTPAuth URI:</strong></p>
|
||||
<code id="totp-uri" class="text-xs text-orange-400 break-all block">{{ totp_uri }}</code>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('profile') }}" class="space-y-4">
|
||||
<input type="hidden" name="action" value="enable_totp">
|
||||
<div>
|
||||
<label for="otp_code" class="block text-sm font-medium text-neutral-300 mb-2">Enter a code from your authenticator app</label>
|
||||
<input id="otp_code" name="otp_code" type="text" inputmode="numeric" required class="w-full max-w-xs px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors" placeholder="123456">
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Verify and Enable
|
||||
</button>
|
||||
<button formaction="{{ url_for('profile') }}" name="action" value="generate_totp_secret" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Regenerate Secret
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if totp_uri %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const qrContainer = document.getElementById('totp-qrcode');
|
||||
const uriElement = document.getElementById('totp-uri');
|
||||
if (!qrContainer || !uriElement) return;
|
||||
|
||||
const otpUri = uriElement.textContent.trim();
|
||||
if (!otpUri) return;
|
||||
|
||||
new QRCode(qrContainer, {
|
||||
text: otpUri,
|
||||
width: 192,
|
||||
height: 192,
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,184 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Prune Backups - OPNsense Backup Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Prune Backups</h1>
|
||||
<p class="text-neutral-400">Delete old backups manually or configure automated retention.</p>
|
||||
</div>
|
||||
|
||||
<!-- Manual Pruning -->
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-neutral-100">Manual Pruning</h2>
|
||||
<span class="text-sm text-neutral-400">Runs immediately</span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('prune_run') }}" class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Scope</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="scope_type" value="all" checked>
|
||||
All Instances
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="scope_type" value="instance">
|
||||
Single Instance
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="scope_instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance (used when Single Instance)</label>
|
||||
<select id="scope_instance_id" name="scope_instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
<option value="">Select instance</option>
|
||||
{% for instance in instances %}
|
||||
<option value="{{ instance.id }}">{{ instance.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Retention Rule</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="keep_mode" value="days" checked>
|
||||
Keep by Days
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="keep_mode" value="count">
|
||||
Keep by Count
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
<div>
|
||||
<label for="keep_days" class="block text-sm font-medium text-neutral-300 mb-2">Days of backups to keep</label>
|
||||
<input id="keep_days" name="keep_days" type="number" min="1" value="14" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label for="keep_count" class="block text-sm font-medium text-neutral-300 mb-2">Backups to keep per instance (newest first)</label>
|
||||
<input id="keep_count" name="keep_count" type="number" min="1" value="10" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div class="text-xs text-neutral-400">
|
||||
If you choose <strong>All Instances</strong>, count-based retention keeps the newest <em>N</em> backups <strong>per instance</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900/40 border border-neutral-700 rounded-md p-4">
|
||||
<label class="flex items-start gap-3 text-sm text-neutral-300">
|
||||
<input type="checkbox" name="confirm_prune" class="mt-1">
|
||||
<span>
|
||||
I understand this will permanently delete backup files and their database records.
|
||||
Only proceed if you're sure about the retention settings.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Prune Now
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Automated Pruning -->
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-neutral-100">Automated Pruning</h2>
|
||||
<span class="text-sm text-neutral-400">Runs in the background</span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('prune_settings') }}" class="space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="checkbox" name="enabled" {% if prune_settings.enabled %}checked{% endif %}>
|
||||
Enable automated pruning
|
||||
</label>
|
||||
<div class="text-xs text-neutral-400">
|
||||
Interval is <strong>hours</strong>. Default: once per day.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Scope</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="scope_type" value="all" {% if prune_settings.scope_type != 'instance' %}checked{% endif %}>
|
||||
All Instances
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="scope_type" value="instance" {% if prune_settings.scope_type == 'instance' %}checked{% endif %}>
|
||||
Single Instance
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label for="auto_scope_instance_id" class="block text-sm font-medium text-neutral-300 mb-2">Instance (used when Single Instance)</label>
|
||||
<select id="auto_scope_instance_id" name="scope_instance_id" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
<option value="">Select instance</option>
|
||||
{% for instance in instances %}
|
||||
<option value="{{ instance.id }}" {% if prune_settings.scope_instance_id == instance.id %}selected{% endif %}>
|
||||
{{ instance.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Retention Rule</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="keep_mode" value="days" {% if prune_settings.keep_days is not none or (prune_settings.keep_days is none and prune_settings.keep_count is none) %}checked{% endif %}>
|
||||
Keep by Days
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="radio" name="keep_mode" value="count" {% if prune_settings.keep_count is not none %}checked{% endif %}>
|
||||
Keep by Count
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
<div>
|
||||
<label for="auto_keep_days" class="block text-sm font-medium text-neutral-300 mb-2">Days of backups to keep</label>
|
||||
<input id="auto_keep_days" name="keep_days" type="number" min="1" value="{{ prune_settings.keep_days if prune_settings.keep_days is not none else 14 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label for="auto_keep_count" class="block text-sm font-medium text-neutral-300 mb-2">Backups to keep per instance (newest first)</label>
|
||||
<input id="auto_keep_count" name="keep_count" type="number" min="1" value="{{ prune_settings.keep_count if prune_settings.keep_count is not none else 10 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="interval_hours" class="block text-sm font-medium text-neutral-300 mb-2">Run interval (hours)</label>
|
||||
<input id="interval_hours" name="interval_hours" type="number" min="1" value="{{ (prune_settings.interval_seconds // 3600) if prune_settings.interval_seconds is not none else 24 }}" class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if prune_settings.last_run_at %}
|
||||
<div class="text-sm text-neutral-400">
|
||||
Last automated prune run: <span class="text-neutral-300 font-medium">{{ prune_settings.last_run_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex justify-end gap-3 flex-wrap">
|
||||
<button type="submit" name="action" value="run_now" class="px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Save & Run Now
|
||||
</button>
|
||||
<button type="submit" class="px-5 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Users - OPNsense Backup Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-neutral-100 mb-2">Users</h1>
|
||||
<p class="text-neutral-400">Create and manage user accounts.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 p-6">
|
||||
<h2 class="text-xl font-bold text-neutral-100 mb-4">Create User</h2>
|
||||
<form method="post" action="{{ url_for('create_user') }}" class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||
<div class="md:col-span-1">
|
||||
<label for="username" class="block text-sm font-medium text-neutral-300 mb-2">Username</label>
|
||||
<input id="username" name="username" type="text" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="password" class="block text-sm font-medium text-neutral-300 mb-2">Password</label>
|
||||
<input id="password" name="password" type="password" required class="w-full px-4 py-2 bg-neutral-700 border border-neutral-600 rounded-md text-neutral-100 focus:outline-none focus:border-orange-500 transition-colors">
|
||||
</div>
|
||||
<div class="md:col-span-1 flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input type="checkbox" name="is_admin">
|
||||
Admin
|
||||
</label>
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 hover:bg-orange-500 text-white rounded-md text-sm font-medium transition-colors hover:cursor-pointer">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-neutral-700">
|
||||
<h2 class="text-xl font-bold text-neutral-100">Existing Users</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-neutral-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">TOTP</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-neutral-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-700">
|
||||
{% for user in users %}
|
||||
<tr class="hover:bg-neutral-700/50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-100">{{ user.username }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
|
||||
{% if user.is_admin %}Admin{% else %}User{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-300">
|
||||
{% if user.totp_enabled %}Enabled{% else %}Disabled{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-neutral-400">
|
||||
{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div class="flex gap-4">
|
||||
<form method="post" action="{{ url_for('toggle_user_admin', user_id=user.id) }}">
|
||||
<button type="submit" class="text-orange-500 hover:text-orange-400 hover:cursor-pointer">
|
||||
{% if user.is_admin %}Remove Admin{% else %}Make Admin{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('delete_user', user_id=user.id) }}" onsubmit="return confirm('Delete user {{ user.username }}?');">
|
||||
<button type="submit" class="text-red-500 hover:text-red-400 hover:cursor-pointer">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user