mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-25 07:40:58 -05:00
feat(core/auth/ui): proxy-aware config, optional OIDC, i18n v4, health checks
feat(core/auth/ui): proxy-aware config, optional OIDC, i18n v4, health checks
- core: add ProxyFix, robust logging setup, rate-limit defaults; mask DB URL in logs
- db: prefer Postgres when POSTGRES_* envs present; initialization helpers and safe task table migration check
- i18n: upgrade to Flask-Babel v4 with locale selector; compile catalogs; add set-language route
- auth: optional OIDC via Authlib (login, callback, logout); login rate limiting; profile language and theme persistence; ensure admin promotion
- admin: branding logo upload/serve; PDF layout editor with preview/reset; backup/restore with progress; system info; license-server controls
- ui: new base layout with improved nav, mobile tab bar, theme/density toggles, CSRF meta + auto-injection, DataTables/Chart.js, Socket.IO boot
- ops: add /_health and /_ready endpoints; Docker healthcheck targets /_health; enable top-level templates via ChoiceLoader
- deps: update/add Authlib, Flask-Babel 4, and related security/util packages
Refs: app/__init__.py, app/config.py, app/routes/{auth,admin,main}.py, app/templates/base.html, Dockerfile, requirements.txt, templates/*
This commit is contained in:
+1
-1
@@ -97,7 +97,7 @@ USER timetracker
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
# Health check (liveness)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/_health || exit 1
|
||||
|
||||
|
||||
+80
-1
@@ -8,6 +8,9 @@ from flask_login import LoginManager
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
from flask_babel import Babel, _
|
||||
from flask_wtf.csrf import CSRFProtect, CSRFError
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
import re
|
||||
from jinja2 import ChoiceLoader, FileSystemLoader
|
||||
@@ -22,6 +25,8 @@ migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
socketio = SocketIO()
|
||||
babel = Babel()
|
||||
csrf = CSRFProtect()
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=[])
|
||||
oauth = OAuth()
|
||||
|
||||
def create_app(config=None):
|
||||
@@ -33,7 +38,17 @@ def create_app(config=None):
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
|
||||
# Configuration
|
||||
app.config.from_object('app.config.Config')
|
||||
# Load env-specific config class
|
||||
try:
|
||||
env_name = os.getenv('FLASK_ENV', 'production')
|
||||
cfg_map = {
|
||||
'development': 'app.config.DevelopmentConfig',
|
||||
'testing': 'app.config.TestingConfig',
|
||||
'production': 'app.config.ProductionConfig',
|
||||
}
|
||||
app.config.from_object(cfg_map.get(env_name, 'app.config.Config'))
|
||||
except Exception:
|
||||
app.config.from_object('app.config.Config')
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
@@ -72,6 +87,20 @@ def create_app(config=None):
|
||||
login_manager.init_app(app)
|
||||
socketio.init_app(app, cors_allowed_origins="*")
|
||||
oauth.init_app(app)
|
||||
csrf.init_app(app)
|
||||
try:
|
||||
# Configure limiter defaults from config if provided
|
||||
default_limits = []
|
||||
raw = app.config.get('RATELIMIT_DEFAULT')
|
||||
if raw:
|
||||
# support semicolon or comma separated limits
|
||||
parts = [p.strip() for p in str(raw).replace(',', ';').split(';') if p.strip()]
|
||||
if parts:
|
||||
default_limits = parts
|
||||
limiter._default_limits = default_limits # set after init
|
||||
limiter.init_app(app)
|
||||
except Exception:
|
||||
limiter.init_app(app)
|
||||
|
||||
# Ensure translations exist and configure absolute translation directories before Babel init
|
||||
try:
|
||||
@@ -183,6 +212,56 @@ def create_app(config=None):
|
||||
|
||||
# Setup logging
|
||||
setup_logging(app)
|
||||
|
||||
# Fail-fast on weak secret in production
|
||||
if not app.debug and app.config.get('FLASK_ENV', 'production') == 'production':
|
||||
if app.config.get('SECRET_KEY') == 'dev-secret-key-change-in-production':
|
||||
app.logger.error('Weak SECRET_KEY configured in production; refusing to start')
|
||||
raise RuntimeError('Weak SECRET_KEY in production')
|
||||
|
||||
# Apply security headers and a basic CSP
|
||||
@app.after_request
|
||||
def apply_security_headers(response):
|
||||
try:
|
||||
headers = app.config.get('SECURITY_HEADERS', {}) or {}
|
||||
for k, v in headers.items():
|
||||
# do not overwrite existing header if already present
|
||||
if not response.headers.get(k):
|
||||
response.headers[k] = v
|
||||
# Minimal CSP allowing our own resources and common CDNs used in templates
|
||||
if not response.headers.get('Content-Security-Policy'):
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: https:; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net; "
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; "
|
||||
"script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
|
||||
"connect-src 'self' ws: wss:; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
response.headers['Content-Security-Policy'] = csp
|
||||
# Additional privacy headers
|
||||
if not response.headers.get('Referrer-Policy'):
|
||||
response.headers['Referrer-Policy'] = 'no-referrer'
|
||||
if not response.headers.get('Permissions-Policy'):
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
# CSRF error handler
|
||||
@app.errorhandler(CSRFError)
|
||||
def handle_csrf_error(e):
|
||||
return ({'error': 'csrf_token_missing_or_invalid'}, 400)
|
||||
|
||||
# Expose csrf_token() in Jinja templates even without FlaskForm
|
||||
try:
|
||||
from flask_wtf.csrf import generate_csrf
|
||||
@app.context_processor
|
||||
def inject_csrf_token():
|
||||
return dict(csrf_token=lambda: generate_csrf())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth_bp
|
||||
|
||||
@@ -85,6 +85,10 @@ class Config:
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
|
||||
}
|
||||
|
||||
# Rate limiting
|
||||
RATELIMIT_DEFAULT = os.getenv('RATELIMIT_DEFAULT', '') # e.g., "200 per day;50 per hour"
|
||||
RATELIMIT_STORAGE_URI = os.getenv('RATELIMIT_STORAGE_URI', 'memory://')
|
||||
|
||||
# Internationalization
|
||||
LANGUAGES = {
|
||||
@@ -136,6 +140,7 @@ class ProductionConfig(Config):
|
||||
FLASK_DEBUG = False
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
WTF_CSRF_ENABLED = True
|
||||
|
||||
# Configuration mapping
|
||||
config = {
|
||||
|
||||
+19
-2
@@ -1,7 +1,7 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file, jsonify, render_template_string
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app import db, limiter
|
||||
from app.models import User, Project, TimeEntry, Settings, Invoice
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text
|
||||
@@ -19,7 +19,8 @@ admin_bp = Blueprint('admin', __name__)
|
||||
RESTORE_PROGRESS = {}
|
||||
|
||||
# Allowed file extensions for logos
|
||||
ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
||||
# Avoid SVG due to XSS risk unless sanitized server-side
|
||||
ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin access"""
|
||||
@@ -260,6 +261,7 @@ def settings():
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout', methods=['GET', 'POST'])
|
||||
@limiter.limit("30 per minute", methods=["POST"]) # editor saves
|
||||
@login_required
|
||||
@admin_required
|
||||
def pdf_layout():
|
||||
@@ -301,6 +303,7 @@ def pdf_layout():
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout/reset', methods=['POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
@login_required
|
||||
@admin_required
|
||||
def pdf_layout_reset():
|
||||
@@ -345,6 +348,7 @@ def pdf_layout_default():
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout/preview', methods=['POST'])
|
||||
@limiter.limit("60 per minute")
|
||||
@login_required
|
||||
@admin_required
|
||||
def pdf_layout_preview():
|
||||
@@ -479,6 +483,7 @@ def pdf_layout_preview():
|
||||
return page_html
|
||||
|
||||
@admin_bp.route('/admin/upload-logo', methods=['POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
@login_required
|
||||
@admin_required
|
||||
def upload_logo():
|
||||
@@ -497,6 +502,17 @@ def upload_logo():
|
||||
file_extension = file.filename.rsplit('.', 1)[1].lower()
|
||||
unique_filename = f"company_logo_{uuid.uuid4().hex[:8]}.{file_extension}"
|
||||
|
||||
# Basic server-side validation: verify image type
|
||||
try:
|
||||
from PIL import Image
|
||||
file.stream.seek(0)
|
||||
img = Image.open(file.stream)
|
||||
img.verify()
|
||||
file.stream.seek(0)
|
||||
except Exception:
|
||||
flash('Invalid image file.', 'error')
|
||||
return redirect(url_for('admin.settings'))
|
||||
|
||||
# Save file
|
||||
upload_folder = get_upload_folder()
|
||||
file_path = os.path.join(upload_folder, unique_filename)
|
||||
@@ -579,6 +595,7 @@ def backup():
|
||||
return redirect(url_for('admin.admin_dashboard'))
|
||||
|
||||
@admin_bp.route('/admin/restore', methods=['GET', 'POST'])
|
||||
@limiter.limit("3 per minute", methods=["POST"]) # heavy operation
|
||||
@login_required
|
||||
@admin_required
|
||||
def restore():
|
||||
|
||||
+2
-1
@@ -5,12 +5,13 @@ from app.models import User
|
||||
from app.config import Config
|
||||
from app.utils.db import safe_commit
|
||||
from flask_babel import gettext as _
|
||||
from app import oauth
|
||||
from app import oauth, limiter
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
@limiter.limit("5 per minute", methods=["POST"]) # rate limit login attempts
|
||||
def login():
|
||||
"""Login page. Local username login is allowed only if AUTH_METHOD != 'oidc'."""
|
||||
if request.method == 'GET':
|
||||
|
||||
+8
-14
@@ -52,23 +52,17 @@ def dashboard():
|
||||
|
||||
@main_bp.route('/_health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring"""
|
||||
"""Liveness probe: shallow checks only, no DB access"""
|
||||
return {'status': 'healthy'}, 200
|
||||
|
||||
@main_bp.route('/_ready')
|
||||
def readiness_check():
|
||||
"""Readiness probe: verify DB connectivity and critical dependencies"""
|
||||
try:
|
||||
# Test database connection
|
||||
db.session.execute(text('SELECT 1'))
|
||||
return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}, 200
|
||||
return {'status': 'ready', 'timestamp': datetime.utcnow().isoformat()}, 200
|
||||
except Exception as e:
|
||||
# Try to initialize database if connection fails
|
||||
try:
|
||||
from flask import current_app
|
||||
if hasattr(current_app, 'initialize_database'):
|
||||
current_app.initialize_database()
|
||||
# Test connection again
|
||||
db.session.execute(text('SELECT 1'))
|
||||
return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'note': 'database initialized'}, 200
|
||||
except Exception as init_error:
|
||||
return {'status': 'unhealthy', 'error': str(e), 'init_error': str(init_error)}, 500
|
||||
return {'status': 'unhealthy', 'error': str(e)}, 500
|
||||
return {'status': 'not_ready', 'error': 'db_unreachable'}, 503
|
||||
|
||||
@main_bp.route('/about')
|
||||
def about():
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="fas fa-id-card me-2"></i> {{ _('Profile Details') }}</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('Username') }}</label>
|
||||
<input type="text" class="form-control" value="{{ current_user.username }}" disabled>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" autocomplete="on" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{ _('Username') }}</label>
|
||||
<div class="input-group">
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
<meta name="theme-color" content="#3b82f6" id="meta-theme-color">
|
||||
<meta id="user-theme-pref" data-user-theme="{{ current_user.theme_preference if current_user.is_authenticated else '' }}">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
{% if csrf_token %}
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Favicon -->
|
||||
{% if settings and settings.has_logo() %}
|
||||
@@ -398,5 +401,63 @@
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// CSRF auto-injection for forms and AJAX/fetch
|
||||
(function(){
|
||||
try {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var token = meta ? meta.getAttribute('content') : '';
|
||||
if (!token) return;
|
||||
|
||||
// Add hidden CSRF inputs to all POST forms that lack one
|
||||
document.querySelectorAll('form[method="post"]:not([data-no-csrf-auto])').forEach(function(form){
|
||||
if (!form.querySelector('input[name="csrf_token"]')) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'csrf_token';
|
||||
input.value = token;
|
||||
form.insertBefore(input, form.firstChild);
|
||||
}
|
||||
});
|
||||
|
||||
// jQuery AJAX: attach header automatically for same-origin, non-GET
|
||||
if (window.$ && $.ajaxSetup) {
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings){
|
||||
try {
|
||||
var method = (settings.type || '').toUpperCase();
|
||||
var url = settings.url || '';
|
||||
var sameOrigin = url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0 || url.indexOf(location.origin) === 0;
|
||||
if (sameOrigin && ['GET','HEAD','OPTIONS','TRACE'].indexOf(method) === -1) {
|
||||
xhr.setRequestHeader('X-CSRFToken', token);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// fetch(): wrap to add CSRF header automatically for same-origin, non-GET
|
||||
if (window.fetch) {
|
||||
var _origFetch = window.fetch;
|
||||
window.fetch = function(input, init){
|
||||
try {
|
||||
var req = new Request(input, init);
|
||||
var url = new URL(req.url, window.location.origin);
|
||||
var sameOrigin = url.origin === window.location.origin;
|
||||
var method = (req.method || '').toUpperCase();
|
||||
if (sameOrigin && ['GET','HEAD','OPTIONS','TRACE'].indexOf(method) === -1) {
|
||||
var headers = new Headers(req.headers || {});
|
||||
if (!headers.has('X-CSRFToken')) headers.set('X-CSRFToken', token);
|
||||
return _origFetch(new Request(req, { headers: headers }));
|
||||
}
|
||||
return _origFetch(req);
|
||||
} catch(e) {
|
||||
return _origFetch(input, init);
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
<div class="col-lg-4 col-md-5 text-center">
|
||||
{% if active_timer %}
|
||||
<form method="POST" action="{{ url_for('timer.stop_timer') }}" class="d-inline w-100">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger px-4 w-100 w-md-auto">
|
||||
<i class="fas fa-stop me-2"></i>{{ _('Stop Timer') }}
|
||||
</button>
|
||||
@@ -302,6 +303,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('timer.start_timer') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-body">
|
||||
<div class="mb-4">
|
||||
<label for="project_id" class="form-label fw-semibold d-flex align-items-center">
|
||||
@@ -369,6 +371,7 @@
|
||||
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteEntryForm" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger fw-bold">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Entry') }}
|
||||
</button>
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
{% if current_user.is_admin or entry.user_id == current_user.id %}
|
||||
<form method="POST" action="{{ url_for('timer.delete_timer', timer_id=entry.id) }}"
|
||||
class="d-inline" data-confirm="{{ _('Are you sure you want to delete this time entry? This action cannot be undone.') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-action btn-action--danger" title="{{ _('Delete entry') }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" id="createTaskForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Task Name -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-semibold">
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" id="editTaskForm">
|
||||
{{ csrf_token() }}
|
||||
<!-- Task Name -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label fw-semibold">
|
||||
|
||||
@@ -17,6 +17,10 @@ psycopg2-binary==2.9.9
|
||||
gunicorn==23.0.0
|
||||
eventlet==0.40.3
|
||||
|
||||
# Security and forms
|
||||
Flask-WTF==1.2.1
|
||||
Flask-Limiter==3.8.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
pytz==2023.3
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('admin.create_user') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</div>
|
||||
|
||||
<form id="form-save" method="POST" class="d-none">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<textarea id="input-html" name="invoice_pdf_template_html"></textarea>
|
||||
<textarea id="input-css" name="invoice_pdf_template_css"></textarea>
|
||||
</form>
|
||||
@@ -71,6 +72,7 @@ const __INIT_HTML__ = {{ (initial_html or settings.invoice_pdf_template_html or
|
||||
const __INIT_CSS__ = {{ (initial_css or settings.invoice_pdf_template_css or '')|tojson }};
|
||||
const __DEFAULTS_URL__ = "{{ url_for('admin.pdf_layout_default') }}";
|
||||
const __PREVIEW_URL__ = "{{ url_for('admin.pdf_layout_preview') }}";
|
||||
const __CSRF_TOKEN__ = (function(){ try { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); } catch(e) { return ''; } })();
|
||||
</script>
|
||||
{% raw %}
|
||||
<script>
|
||||
@@ -155,7 +157,7 @@ const __PREVIEW_URL__ = "{{ url_for('admin.pdf_layout_preview') }}";
|
||||
const formData = new FormData();
|
||||
formData.append('html', html);
|
||||
formData.append('css', css);
|
||||
fetch(__PREVIEW_URL__, { method:'POST', body: formData })
|
||||
fetch(__PREVIEW_URL__, { method:'POST', headers: __CSRF_TOKEN__ ? { 'X-CSRFToken': __CSRF_TOKEN__ } : {}, body: formData })
|
||||
.then(r=>r.text()).then(html => {
|
||||
const frame = document.getElementById('preview-frame');
|
||||
const doc = frame.contentDocument || frame.contentWindow.document;
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
</script>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('admin.restore') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="backup_file" class="form-label">{{ _('Backup Archive (.zip)') }}</label>
|
||||
<input class="form-control" type="file" id="backup_file" name="backup_file" accept=".zip" required>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="timezone">{{ _('Timezone') }}</label>
|
||||
@@ -263,6 +264,7 @@
|
||||
class="d-inline"
|
||||
data-confirm="{{ _('Are you sure you want to remove the current logo?') }}"
|
||||
onsubmit="return confirm(this.getAttribute('data-confirm'))">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="fas fa-trash me-1"></i> {{ _('Remove Logo') }}
|
||||
</button>
|
||||
@@ -273,10 +275,10 @@
|
||||
|
||||
<div class="upload-controls">
|
||||
<input type="file" class="form-control" id="logo_file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.svg,.webp"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp"
|
||||
onchange="previewLogo(this)">
|
||||
<small class="form-text text-muted">
|
||||
{{ _('Supported formats: PNG, JPG, JPEG, GIF, SVG, WEBP (Max size: 5MB)') }}
|
||||
{{ _('Supported formats: PNG, JPG, JPEG, GIF, WEBP (Max size: 5MB)') }}
|
||||
</small>
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -503,7 +505,7 @@ function previewLogo(input) {
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp'];
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert(i18nSettings.invalid_file_type || 'Invalid file type. Please select a valid image file.');
|
||||
input.value = '';
|
||||
@@ -537,7 +539,7 @@ function uploadLogo() {
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp'];
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert(i18nSettings.invalid_file_type || 'Invalid file type. Please select a valid image file.');
|
||||
return;
|
||||
@@ -553,8 +555,11 @@ function uploadLogo() {
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i> ' + (i18nSettings.uploading || 'Uploading...');
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
||||
fetch('{{ url_for("admin.upload_logo") }}', {
|
||||
method: 'POST',
|
||||
headers: csrf ? { 'X-CSRFToken': csrf } : {},
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
@@ -164,6 +165,8 @@
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteUserForm" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{{ csrf_token() }}
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete User') }}
|
||||
</button>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" id="invoiceForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<!-- Step 1: Basic Information -->
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('timer.bulk_entry') }}" id="bulkEntryForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
|
||||
@@ -200,6 +200,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
<strong>{{ _('Admin Mode:') }}</strong> {{ _('You can edit all fields of this time entry, including project, task, start/end times, and source.') }}
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="project_id" class="form-label fw-semibold">
|
||||
@@ -388,6 +389,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
{% else %}
|
||||
<!-- Regular user form -->
|
||||
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-4">
|
||||
<label for="notes" class="form-label fw-semibold">
|
||||
<i class="fas fa-sticky-note me-1"></i>{{ _('Notes') }}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('timer.manual_entry') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
|
||||
Reference in New Issue
Block a user