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:
Dries Peeters
2025-10-05 17:48:54 +02:00
parent 9425e02127
commit 9a1603cfd8
23 changed files with 211 additions and 25 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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():
+2 -1
View File
@@ -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>
+1
View File
@@ -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">
+61
View File
@@ -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>
+3
View File
@@ -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>
+1
View File
@@ -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>
+1
View File
@@ -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">
+1
View File
@@ -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">
+4
View File
@@ -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
+1
View File
@@ -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">
+3 -1
View File
@@ -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;
+1
View File
@@ -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>
+9 -4
View File
@@ -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 => {
+3
View File
@@ -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>
+1
View File
@@ -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 -->
+1
View File
@@ -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">
+2
View File
@@ -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') }}
+1
View File
@@ -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">