From 452f3abd808cf03b501ecce501a5022350c12ef4 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 09:33:39 +0200 Subject: [PATCH 01/14] Initial user management. --- app.py | 337 +++++++++++++++++++++++++++++++++---- migrate_db.py | 52 +++++- models.py | 23 +++ static/js/script.js | 31 ++++ templates/404.html | 9 + templates/500.html | 0 templates/admin_users.html | 88 ++++++++++ templates/create_user.html | 44 +++++ templates/edit_user.html | 44 +++++ templates/layout.html | 39 ++++- templates/login.html | 42 +++++ templates/profile.html | 49 ++++++ templates/register.html | 45 +++++ 13 files changed, 766 insertions(+), 37 deletions(-) create mode 100644 templates/404.html create mode 100644 templates/500.html create mode 100644 templates/admin_users.html create mode 100644 templates/create_user.html create mode 100644 templates/edit_user.html create mode 100644 templates/login.html create mode 100644 templates/profile.html create mode 100644 templates/register.html diff --git a/app.py b/app.py index 9ec27d3..6a66c0a 100644 --- a/app.py +++ b/app.py @@ -1,54 +1,310 @@ -from flask import Flask, render_template, request, redirect, url_for, jsonify, flash -from models import db, TimeEntry, WorkConfig -from datetime import datetime, time +from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g +from models import db, TimeEntry, WorkConfig, User +import logging +from datetime import datetime, time, timedelta import os from sqlalchemy import func +from functools import wraps + +# Configure logging +logging.basicConfig(level=logging.DEBUG) app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack') # Add secret key for flash messages +app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack') +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days # Initialize the database with the app db.init_app(app) +# Authentication decorator +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + flash('Please log in to access this page', 'error') + return redirect(url_for('login', next=request.url)) + return f(*args, **kwargs) + return decorated_function + +# Admin-only decorator +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + flash('Please log in to access this page', 'error') + return redirect(url_for('login', next=request.url)) + + user = User.query.get(session['user_id']) + if not user or not user.is_admin: + flash('You need administrator privileges to access this page', 'error') + return redirect(url_for('home')) + return f(*args, **kwargs) + return decorated_function + +@app.before_request +def load_logged_in_user(): + user_id = session.get('user_id') + if user_id is None: + g.user = None + else: + g.user = User.query.get(user_id) + @app.route('/') def home(): - # Get active time entry (if any) - active_entry = TimeEntry.query.filter_by(departure_time=None).first() + if g.user: + # Get active time entry (if any) for the current user + active_entry = TimeEntry.query.filter_by(user_id=g.user.id, departure_time=None).first() - # Get today's date - today = datetime.now().date() + # Get today's date + today = datetime.now().date() - # Get time entries for today only - today_start = datetime.combine(today, time.min) - today_end = datetime.combine(today, time.max) + # Get time entries for today only for the current user + today_start = datetime.combine(today, time.min) + today_end = datetime.combine(today, time.max) - today_entries = TimeEntry.query.filter( - TimeEntry.arrival_time >= today_start, - TimeEntry.arrival_time <= today_end - ).order_by(TimeEntry.arrival_time.desc()).all() + today_entries = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.arrival_time >= today_start, + TimeEntry.arrival_time <= today_end + ).order_by(TimeEntry.arrival_time.desc()).all() - return render_template('index.html', title='Home', active_entry=active_entry, history=today_entries) + return render_template('index.html', title='Home', active_entry=active_entry, history=today_entries) + else: + # Show landing page for non-logged in users + return render_template('index.html', title='Home') + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + remember = 'remember' in request.form + + user = User.query.filter_by(username=username).first() + + if user and user.check_password(password): + session.clear() + session['user_id'] = user.id + if remember: + session.permanent = True + + next_page = request.args.get('next') + if not next_page or not next_page.startswith('/'): + next_page = url_for('home') + + flash(f'Welcome back, {user.username}!', 'success') + return redirect(next_page) + + flash('Invalid username or password', 'error') + + return render_template('login.html', title='Login') + +@app.route('/logout') +def logout(): + session.clear() + flash('You have been logged out', 'info') + return redirect(url_for('login')) + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + + # Validate input + error = None + if not username: + error = 'Username is required' + elif not email: + error = 'Email is required' + elif not password: + error = 'Password is required' + elif password != confirm_password: + error = 'Passwords do not match' + elif User.query.filter_by(username=username).first(): + error = 'Username already exists' + elif User.query.filter_by(email=email).first(): + error = 'Email already registered' + + if error is None: + new_user = User(username=username, email=email) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + flash('Registration successful! You can now log in.', 'success') + return redirect(url_for('login')) + + flash(error, 'error') + + return render_template('register.html', title='Register') + +@app.route('/admin/users') +@admin_required +def admin_users(): + users = User.query.all() + return render_template('admin_users.html', title='User Management', users=users) + +@app.route('/admin/users/create', methods=['GET', 'POST']) +@admin_required +def create_user(): + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + is_admin = 'is_admin' in request.form + + # Validate input + error = None + if not username: + error = 'Username is required' + elif not email: + error = 'Email is required' + elif not password: + error = 'Password is required' + elif User.query.filter_by(username=username).first(): + error = 'Username already exists' + elif User.query.filter_by(email=email).first(): + error = 'Email already registered' + + if error is None: + new_user = User(username=username, email=email, is_admin=is_admin) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + flash(f'User {username} created successfully!', 'success') + return redirect(url_for('admin_users')) + + flash(error, 'error') + + return render_template('create_user.html', title='Create User') + +@app.route('/admin/users/edit/', methods=['GET', 'POST']) +@admin_required +def edit_user(user_id): + user = User.query.get_or_404(user_id) + + if request.method == 'POST': + username = request.form.get('username') + email = request.form.get('email') + password = request.form.get('password') + is_admin = 'is_admin' in request.form + + # Validate input + error = None + if not username: + error = 'Username is required' + elif not email: + error = 'Email is required' + elif username != user.username and User.query.filter_by(username=username).first(): + error = 'Username already exists' + elif email != user.email and User.query.filter_by(email=email).first(): + error = 'Email already registered' + + if error is None: + user.username = username + user.email = email + user.is_admin = is_admin + + if password: + user.set_password(password) + + db.session.commit() + + flash(f'User {username} updated successfully!', 'success') + return redirect(url_for('admin_users')) + + flash(error, 'error') + + return render_template('edit_user.html', title='Edit User', user=user) + +@app.route('/admin/users/delete/', methods=['POST']) +@admin_required +def delete_user(user_id): + user = User.query.get_or_404(user_id) + + # Prevent deleting yourself + if user.id == session.get('user_id'): + flash('You cannot delete your own account', 'error') + return redirect(url_for('admin_users')) + + username = user.username + db.session.delete(user) + db.session.commit() + + flash(f'User {username} deleted successfully', 'success') + return redirect(url_for('admin_users')) + +@app.route('/profile', methods=['GET', 'POST']) +@login_required +def profile(): + user = User.query.get(session['user_id']) + + if request.method == 'POST': + email = request.form.get('email') + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + # Validate input + error = None + if not email: + error = 'Email is required' + elif email != user.email and User.query.filter_by(email=email).first(): + error = 'Email already registered' + + # Password change validation + if new_password: + if not current_password: + error = 'Current password is required to set a new password' + elif not user.check_password(current_password): + error = 'Current password is incorrect' + elif new_password != confirm_password: + error = 'New passwords do not match' + + if error is None: + user.email = email + + if new_password: + user.set_password(new_password) + + db.session.commit() + flash('Profile updated successfully!', 'success') + return redirect(url_for('profile')) + + flash(error, 'error') + + return render_template('profile.html', title='My Profile', user=user) @app.route('/about') +@login_required def about(): return render_template('about.html', title='About') @app.route('/contact', methods=['GET', 'POST']) +@login_required def contact(): # redacted return render_template('contact.html', title='Contact') # We can keep this route as a redirect to home for backward compatibility @app.route('/timetrack') +@login_required def timetrack(): return redirect(url_for('home')) @app.route('/api/arrive', methods=['POST']) +@login_required def arrive(): - # Create a new time entry with arrival time - new_entry = TimeEntry(arrival_time=datetime.now()) + # Create a new time entry with arrival time for the current user + new_entry = TimeEntry(user_id=session['user_id'], arrival_time=datetime.now()) db.session.add(new_entry) db.session.commit() @@ -58,9 +314,10 @@ def arrive(): }) @app.route('/api/leave/', methods=['POST']) +@login_required def leave(entry_id): - # Find the time entry - entry = TimeEntry.query.get_or_404(entry_id) + # Find the time entry for the current user + entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404() # Set the departure time departure_time = datetime.now() @@ -93,9 +350,10 @@ def leave(entry_id): # Add this new route to handle pausing/resuming @app.route('/api/toggle-pause/', methods=['POST']) +@login_required def toggle_pause(entry_id): - # Find the time entry - entry = TimeEntry.query.get_or_404(entry_id) + # Find the time entry for the current user + entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404() now = datetime.now() @@ -124,6 +382,7 @@ def toggle_pause(entry_id): }) @app.route('/config', methods=['GET', 'POST']) +@login_required def config(): # Get current configuration or create default if none exists config = WorkConfig.query.order_by(WorkConfig.id.desc()).first() @@ -164,15 +423,17 @@ def create_tables(): print("WARNING: Database schema is outdated. Please run migrate_db.py to update it.") @app.route('/api/delete/', methods=['DELETE']) +@login_required def delete_entry(entry_id): - entry = TimeEntry.query.get_or_404(entry_id) + entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404() db.session.delete(entry) db.session.commit() return jsonify({'success': True, 'message': 'Entry deleted successfully'}) @app.route('/api/update/', methods=['PUT']) +@login_required def update_entry(entry_id): - entry = TimeEntry.query.get_or_404(entry_id) + entry = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404() data = request.json if 'arrival_time' in data: @@ -210,9 +471,10 @@ def update_entry(entry_id): }) @app.route('/history') +@login_required def history(): - # Get all time entries, ordered by most recent first - all_entries = TimeEntry.query.order_by(TimeEntry.arrival_time.desc()).all() + # Get all time entries for the current user, ordered by most recent first + all_entries = TimeEntry.query.filter_by(user_id=session['user_id']).order_by(TimeEntry.arrival_time.desc()).all() return render_template('history.html', title='Time Entry History', entries=all_entries) @@ -263,12 +525,13 @@ def calculate_work_duration(arrival_time, departure_time, total_break_duration): return work_duration, effective_break_duration @app.route('/api/resume/', methods=['POST']) +@login_required def resume_entry(entry_id): - # Find the entry to resume - entry_to_resume = TimeEntry.query.get_or_404(entry_id) + # Find the entry to resume for the current user + entry_to_resume = TimeEntry.query.filter_by(id=entry_id, user_id=session['user_id']).first_or_404() # Check if there's already an active entry - active_entry = TimeEntry.query.filter_by(departure_time=None).first() + active_entry = TimeEntry.query.filter_by(user_id=session['user_id'], departure_time=None).first() if active_entry: return jsonify({ 'success': False, @@ -292,5 +555,21 @@ def resume_entry(entry_id): 'total_break_duration': entry_to_resume.total_break_duration }) +@app.errorhandler(404) +def page_not_found(e): + return render_template('404.html'), 404 + +@app.errorhandler(500) +def internal_server_error(e): + return render_template('500.html'), 500 + +@app.route('/test') +def test(): + return "App is working!" + +@app.context_processor +def inject_current_year(): + return {'current_year': datetime.now().year} + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py index e78e85e..b409630 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -1,6 +1,9 @@ from app import app, db import sqlite3 import os +from models import User, TimeEntry, WorkConfig +from werkzeug.security import generate_password_hash +from datetime import datetime def migrate_database(): db_path = 'timetrack.db' @@ -34,6 +37,11 @@ def migrate_database(): if 'total_break_duration' not in time_entry_columns: print("Adding total_break_duration column to time_entry...") cursor.execute("ALTER TABLE time_entry ADD COLUMN total_break_duration INTEGER DEFAULT 0") + + # Add user_id column if it doesn't exist + if 'user_id' not in time_entry_columns: + print("Adding user_id column to time_entry...") + cursor.execute("ALTER TABLE time_entry ADD COLUMN user_id INTEGER") # Check if the work_config table exists cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='work_config'") @@ -46,7 +54,8 @@ def migrate_database(): mandatory_break_minutes INTEGER DEFAULT 30, break_threshold_hours FLOAT DEFAULT 6.0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER ) """) # Insert default config @@ -67,12 +76,49 @@ def migrate_database(): if 'additional_break_threshold_hours' not in work_config_columns: print("Adding additional_break_threshold_hours column to work_config...") cursor.execute("ALTER TABLE work_config ADD COLUMN additional_break_threshold_hours FLOAT DEFAULT 9.0") + + # Add user_id column to work_config if it doesn't exist + if 'user_id' not in work_config_columns: + print("Adding user_id column to work_config...") + cursor.execute("ALTER TABLE work_config ADD COLUMN user_id INTEGER") # Commit changes and close connection conn.commit() conn.close() - print("Database migration completed successfully!") + with app.app_context(): + # Create tables if they don't exist + db.create_all() + + # Check if admin user exists + admin = User.query.filter_by(username='admin').first() + if not admin: + # Create admin user + admin = User( + username='admin', + email='admin@timetrack.local', + is_admin=True + ) + admin.set_password('admin') # Default password, should be changed + db.session.add(admin) + db.session.commit() + print("Created admin user with username 'admin' and password 'admin'") + print("Please change the admin password after first login!") + + # Update existing time entries to associate with admin user + orphan_entries = TimeEntry.query.filter_by(user_id=None).all() + for entry in orphan_entries: + entry.user_id = admin.id + + # Update existing work configs to associate with admin user + orphan_configs = WorkConfig.query.filter_by(user_id=None).all() + for config in orphan_configs: + config.user_id = admin.id + + db.session.commit() + print(f"Associated {len(orphan_entries)} existing time entries with admin user") + print(f"Associated {len(orphan_configs)} existing work configs with admin user") if __name__ == "__main__": - migrate_database() \ No newline at end of file + migrate_database() + print("Database migration completed") \ No newline at end of file diff --git a/models.py b/models.py index 1e92c1b..aeb3d2c 100644 --- a/models.py +++ b/models.py @@ -1,8 +1,29 @@ from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime db = SQLAlchemy() +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(128)) + is_admin = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationship with TimeEntry + time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic') + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def __repr__(self): + return f'' + class TimeEntry(db.Model): id = db.Column(db.Integer, primary_key=True) arrival_time = db.Column(db.DateTime, nullable=False) @@ -11,6 +32,7 @@ class TimeEntry(db.Model): is_paused = db.Column(db.Boolean, default=False) pause_start_time = db.Column(db.DateTime, nullable=True) total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) def __repr__(self): return f'' @@ -24,6 +46,7 @@ class WorkConfig(db.Model): additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Work hours that trigger additional break created_at = db.Column(db.DateTime, default=datetime.now) updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) def __repr__(self): return f'' \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js index 1fb14b9..a3c7bae 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -132,6 +132,37 @@ document.addEventListener('DOMContentLoaded', function() { }); }); } + + // Add dropdown menu functionality + const dropdownToggles = document.querySelectorAll('.dropdown-toggle'); + + dropdownToggles.forEach(toggle => { + toggle.addEventListener('click', function(e) { + e.preventDefault(); + const parent = this.parentElement; + const menu = parent.querySelector('.dropdown-menu'); + + // Close all other open dropdowns + document.querySelectorAll('.dropdown-menu').forEach(item => { + if (item !== menu && item.classList.contains('show')) { + item.classList.remove('show'); + } + }); + + // Toggle current dropdown + menu.classList.toggle('show'); + }); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', function(e) { + if (!e.target.matches('.dropdown-toggle')) { + const dropdowns = document.querySelectorAll('.dropdown-menu.show'); + dropdowns.forEach(dropdown => { + dropdown.classList.remove('show'); + }); + } + }); }); // Add event listener for resume work buttons diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..0ea8250 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} + +{% block content %} +
+

404 - Page Not Found

+

The page you are looking for does not exist.

+ Return to Home +
+{% endblock %} \ No newline at end of file diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100644 index 0000000..ed6cd72 --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,88 @@ +{% extends "layout.html" %} + +{% block content %} +
+

User Management

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + + +
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + {% endfor %} + +
UsernameEmailRoleCreatedActions
{{ user.username }}{{ user.email }}{% if user.is_admin %}Admin{% else %}User{% endif %}{{ user.created_at.strftime('%Y-%m-%d') }} + Edit + {% if user.id != g.user.id %} + + {% endif %} +
+
+ + + + + +
+{% endblock %} \ No newline at end of file diff --git a/templates/create_user.html b/templates/create_user.html new file mode 100644 index 0000000..9480822 --- /dev/null +++ b/templates/create_user.html @@ -0,0 +1,44 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Create New User

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + Cancel +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/edit_user.html b/templates/edit_user.html new file mode 100644 index 0000000..1a14590 --- /dev/null +++ b/templates/edit_user.html @@ -0,0 +1,44 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Edit User: {{ user.username }}

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + Cancel +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index aee3363..7883d7d 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -3,7 +3,7 @@ - TimeTrack - {{ title }} + {{ title }} - TimeTrack @@ -11,19 +11,48 @@
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %}
-

© 2025 TimeTrack

+

© {{ current_year }} TimeTrack. All rights reserved.

diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..5656a69 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,42 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Login to TimeTrack

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..b093036 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,49 @@ +{% extends "layout.html" %} + +{% block content %} +
+

My Profile

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+

Username: {{ user.username }}

+

Account Type: {% if user.is_admin %}Administrator{% else %}User{% endif %}

+

Member Since: {{ user.created_at.strftime('%Y-%m-%d') }}

+
+ +

Update Profile

+
+
+ + +
+ +

Change Password

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..367540b --- /dev/null +++ b/templates/register.html @@ -0,0 +1,45 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Register for TimeTrack

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+{% endblock %} \ No newline at end of file From 5fa044e69c841dff665a3c3e90df81c4da4d68d4 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 09:48:29 +0200 Subject: [PATCH 02/14] Change navigation. --- app.py | 5 ++ static/css/style.css | 92 +++++++++++++++++++++++++++++++++- static/js/script.js | 28 +++++------ templates/admin_dashboard.html | 32 ++++++++++++ templates/layout.html | 12 ++--- 5 files changed, 147 insertions(+), 22 deletions(-) create mode 100644 templates/admin_dashboard.html diff --git a/app.py b/app.py index 6a66c0a..19ba532 100644 --- a/app.py +++ b/app.py @@ -144,6 +144,11 @@ def register(): return render_template('register.html', title='Register') +@app.route('/admin/dashboard') +@admin_required +def admin_dashboard(): + return render_template('admin_dashboard.html', title='Admin Dashboard') + @app.route('/admin/users') @admin_required def admin_users(): diff --git a/static/css/style.css b/static/css/style.css index 7bd9747..a06eb35 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -17,8 +17,11 @@ header { nav ul { display: flex; + justify-content: flex-start; + align-items: center; list-style: none; - justify-content: center; + padding: 0; + margin: 0; } nav ul li { @@ -30,6 +33,61 @@ nav ul li a { text-decoration: none; } +/* Dropdown menu styles */ +nav ul li.dropdown { + position: relative; +} + +nav ul li.admin-dropdown { + margin-left: auto; /* Push to the right */ +} + +nav ul li.dropdown .dropdown-toggle { + cursor: pointer; + padding: 10px 15px; + display: block; + color: #fff; + text-decoration: none; +} + +nav ul li.dropdown .dropdown-menu { + display: none; + position: absolute; + right: 0; /* Align to the right for admin dropdown */ + background-color: #333; + min-width: 180px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 1000; + padding: 10px 0; + border-radius: 4px; + margin-top: 5px; +} + +/* Show dropdown on hover and keep it visible when hovering over the menu */ +nav ul li.dropdown:hover .dropdown-menu, +nav ul li.dropdown .dropdown-menu:hover { + display: block; +} + +nav ul li.dropdown .dropdown-menu li { + display: block; + padding: 0; + width: 100%; +} + +nav ul li.dropdown .dropdown-menu li a { + padding: 10px 20px; + display: block; + text-align: left; + color: #fff; + text-decoration: none; + transition: background-color 0.2s ease; +} + +nav ul li.dropdown .dropdown-menu li a:hover { + background-color: #444; +} + main { max-width: 1200px; margin: 2rem auto; @@ -413,4 +471,36 @@ input[type="time"]::-webkit-datetime-edit { color: #666; margin-top: 4px; font-style: italic; +} + +/* Admin Dashboard Styles */ +.admin-panel { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-top: 20px; +} + +.admin-card { + background-color: #f8f9fa; + border-radius: 5px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + width: 300px; + transition: transform 0.2s, box-shadow 0.2s; +} + +.admin-card:hover { + transform: translateY(-5px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); +} + +.admin-card h2 { + margin-top: 0; + color: #333; +} + +.admin-card p { + color: #666; + margin-bottom: 20px; } \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js index a3c7bae..51ba52f 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -142,24 +142,24 @@ document.addEventListener('DOMContentLoaded', function() { const parent = this.parentElement; const menu = parent.querySelector('.dropdown-menu'); - // Close all other open dropdowns - document.querySelectorAll('.dropdown-menu').forEach(item => { - if (item !== menu && item.classList.contains('show')) { - item.classList.remove('show'); - } - }); - - // Toggle current dropdown - menu.classList.toggle('show'); + // Toggle the display of the dropdown menu + if (menu.style.display === 'block') { + menu.style.display = 'none'; + } else { + // Close all other open dropdowns first + document.querySelectorAll('.dropdown-menu').forEach(m => { + if (m !== menu) m.style.display = 'none'; + }); + menu.style.display = 'block'; + } }); }); - // Close dropdown when clicking outside + // Close dropdowns when clicking outside document.addEventListener('click', function(e) { - if (!e.target.matches('.dropdown-toggle')) { - const dropdowns = document.querySelectorAll('.dropdown-menu.show'); - dropdowns.forEach(dropdown => { - dropdown.classList.remove('show'); + if (!e.target.closest('.dropdown')) { + document.querySelectorAll('.dropdown-menu').forEach(menu => { + menu.style.display = 'none'; }); } }); diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..73a029c --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,32 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Admin Dashboard

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+

User Management

+

Manage user accounts, permissions, and roles.

+ Manage Users +
+ + + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 7883d7d..618f7c9 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -15,20 +15,18 @@
  • History
  • Config
  • About
  • -
  • Contact
  • - + {% if g.user.is_admin %} - {% endif %} - -
  • Profile
  • -
  • Logout
  • {% else %}
  • Login
  • Register
  • From 44809e34f083dd093cc6dc3d5be6d210b4d63293 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 10:33:18 +0200 Subject: [PATCH 03/14] Require registration by mail link. --- app.py | 185 ++++++++++++++++++++++++++----------- migrate_db.py | 32 ++++++- models.py | 30 +++++- templates/create_user.html | 7 ++ templates/layout.html | 12 ++- templates/register.html | 5 + 6 files changed, 212 insertions(+), 59 deletions(-) diff --git a/app.py b/app.py index 19ba532..f33616c 100644 --- a/app.py +++ b/app.py @@ -5,9 +5,15 @@ from datetime import datetime, time, timedelta import os from sqlalchemy import func from functools import wraps +from flask_mail import Mail, Message +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() # Configure logging logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db' @@ -15,6 +21,23 @@ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack') app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days +# Configure Flask-Mail +app.config['MAIL_SERVER'] = os.environ.get('MAIL_SERVER', 'smtp.example.com') +app.config['MAIL_PORT'] = int(os.environ.get('MAIL_PORT', 587)) +app.config['MAIL_USE_TLS'] = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1'] +app.config['MAIL_USERNAME'] = os.environ.get('MAIL_USERNAME', 'your-email@example.com') +app.config['MAIL_PASSWORD'] = os.environ.get('MAIL_PASSWORD', 'your-password') +app.config['MAIL_DEFAULT_SENDER'] = os.environ.get('MAIL_DEFAULT_SENDER', 'TimeTrack ') + +# Log mail configuration (without password) +logger.info(f"Mail server: {app.config['MAIL_SERVER']}") +logger.info(f"Mail port: {app.config['MAIL_PORT']}") +logger.info(f"Mail use TLS: {app.config['MAIL_USE_TLS']}") +logger.info(f"Mail username: {app.config['MAIL_USERNAME']}") +logger.info(f"Mail default sender: {app.config['MAIL_DEFAULT_SENDER']}") + +mail = Mail(app) + # Initialize the database with the app db.init_app(app) @@ -22,8 +45,7 @@ db.init_app(app) def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): - if 'user_id' not in session: - flash('Please log in to access this page', 'error') + if g.user is None: return redirect(url_for('login', next=request.url)) return f(*args, **kwargs) return decorated_function @@ -32,13 +54,8 @@ def login_required(f): def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): - if 'user_id' not in session: - flash('Please log in to access this page', 'error') - return redirect(url_for('login', next=request.url)) - - user = User.query.get(session['user_id']) - if not user or not user.is_admin: - flash('You need administrator privileges to access this page', 'error') + if g.user is None or not g.user.is_admin: + flash('You need administrator privileges to access this page.', 'error') return redirect(url_for('home')) return f(*args, **kwargs) return decorated_function @@ -50,29 +67,25 @@ def load_logged_in_user(): g.user = None else: g.user = User.query.get(user_id) + if g.user and not g.user.is_verified and request.endpoint not in ['verify_email', 'static', 'logout']: + # Allow unverified users to access only verification and static resources + if request.endpoint not in ['login', 'register']: + flash('Please verify your email address before accessing this page.', 'warning') + session.clear() + return redirect(url_for('login')) @app.route('/') def home(): if g.user: - # Get active time entry (if any) for the current user - active_entry = TimeEntry.query.filter_by(user_id=g.user.id, departure_time=None).first() - - # Get today's date today = datetime.now().date() - - # Get time entries for today only for the current user - today_start = datetime.combine(today, time.min) - today_end = datetime.combine(today, time.max) - - today_entries = TimeEntry.query.filter( + entries = TimeEntry.query.filter( TimeEntry.user_id == g.user.id, - TimeEntry.arrival_time >= today_start, - TimeEntry.arrival_time <= today_end + TimeEntry.arrival_time >= datetime.combine(today, time.min), + TimeEntry.arrival_time <= datetime.combine(today, time.max) ).order_by(TimeEntry.arrival_time.desc()).all() - - return render_template('index.html', title='Home', active_entry=active_entry, history=today_entries) + + return render_template('index.html', title='Home', entries=entries) else: - # Show landing page for non-logged in users return render_template('index.html', title='Home') @app.route('/login', methods=['GET', 'POST']) @@ -80,31 +93,27 @@ def login(): if request.method == 'POST': username = request.form.get('username') password = request.form.get('password') - remember = 'remember' in request.form user = User.query.filter_by(username=username).first() - if user and user.check_password(password): - session.clear() - session['user_id'] = user.id - if remember: - session.permanent = True - - next_page = request.args.get('next') - if not next_page or not next_page.startswith('/'): - next_page = url_for('home') - - flash(f'Welcome back, {user.username}!', 'success') - return redirect(next_page) + if user is None or not user.check_password(password): + flash('Invalid username or password', 'error') + return redirect(url_for('login')) - flash('Invalid username or password', 'error') + if not user.is_verified: + flash('Please verify your email address before logging in. Check your inbox for the verification link.', 'warning') + return redirect(url_for('login')) + + session.clear() + session['user_id'] = user.id + return redirect(url_for('home')) return render_template('login.html', title='Login') @app.route('/logout') def logout(): session.clear() - flash('You have been logged out', 'info') + flash('You have been logged out.', 'info') return redirect(url_for('login')) @app.route('/register', methods=['GET', 'POST']) @@ -131,19 +140,62 @@ def register(): error = 'Email already registered' if error is None: - new_user = User(username=username, email=email) - new_user.set_password(password) - - db.session.add(new_user) - db.session.commit() - - flash('Registration successful! You can now log in.', 'success') - return redirect(url_for('login')) + try: + new_user = User(username=username, email=email, is_verified=False) + new_user.set_password(password) + + # Generate verification token + token = new_user.generate_verification_token() + + db.session.add(new_user) + db.session.commit() + + # Send verification email + verification_url = url_for('verify_email', token=token, _external=True) + msg = Message('Verify your TimeTrack account', recipients=[email]) + msg.body = f'''Hello {username}, + +Thank you for registering with TimeTrack. To complete your registration, please click on the link below: + +{verification_url} + +This link will expire in 24 hours. + +If you did not register for TimeTrack, please ignore this email. + +Best regards, +The TimeTrack Team +''' + mail.send(msg) + logger.info(f"Verification email sent to {email}") + + flash('Registration initiated! Please check your email to verify your account.', 'success') + return redirect(url_for('login')) + except Exception as e: + db.session.rollback() + logger.error(f"Error during registration: {str(e)}") + error = f"An error occurred during registration: {str(e)}" flash(error, 'error') return render_template('register.html', title='Register') +@app.route('/verify_email/') +def verify_email(token): + user = User.query.filter_by(verification_token=token).first() + + if not user: + flash('Invalid or expired verification link.', 'error') + return redirect(url_for('login')) + + if user.verify_token(token): + db.session.commit() + flash('Email verified successfully! You can now log in.', 'success') + else: + flash('Invalid or expired verification link.', 'error') + + return redirect(url_for('login')) + @app.route('/admin/dashboard') @admin_required def admin_dashboard(): @@ -163,6 +215,7 @@ def create_user(): email = request.form.get('email') password = request.form.get('password') is_admin = 'is_admin' in request.form + auto_verify = 'auto_verify' in request.form # New checkbox for auto verification # Validate input error = None @@ -178,13 +231,34 @@ def create_user(): error = 'Email already registered' if error is None: - new_user = User(username=username, email=email, is_admin=is_admin) + new_user = User(username=username, email=email, is_admin=is_admin, is_verified=auto_verify) new_user.set_password(password) + if not auto_verify: + # Generate verification token and send email + token = new_user.generate_verification_token() + verification_url = url_for('verify_email', token=token, _external=True) + msg = Message('Verify your TimeTrack account', recipients=[email]) + msg.body = f'''Hello {username}, + +An administrator has created an account for you on TimeTrack. To activate your account, please click on the link below: + +{verification_url} + +This link will expire in 24 hours. + +Best regards, +The TimeTrack Team +''' + mail.send(msg) + db.session.add(new_user) db.session.commit() - flash(f'User {username} created successfully!', 'success') + if auto_verify: + flash(f'User {username} created and automatically verified!', 'success') + else: + flash(f'User {username} created! Verification email sent.', 'success') return redirect(url_for('admin_users')) flash(error, 'error') @@ -422,10 +496,15 @@ def create_tables(): # Check if we need to add new columns from sqlalchemy import inspect inspector = inspect(db.engine) - columns = [column['name'] for column in inspector.get_columns('time_entry')] - - if 'is_paused' not in columns or 'pause_start_time' not in columns or 'total_break_duration' not in columns: - print("WARNING: Database schema is outdated. Please run migrate_db.py to update it.") + + # Check if user table exists + if 'user' in inspector.get_table_names(): + columns = [column['name'] for column in inspector.get_columns('user')] + + # Check for verification columns + if 'is_verified' not in columns or 'verification_token' not in columns or 'token_expiry' not in columns: + logger.warning("Database schema is outdated. Please run migrate_db.py to update it.") + print("WARNING: Database schema is outdated. Please run migrate_db.py to update it.") @app.route('/api/delete/', methods=['DELETE']) @login_required diff --git a/migrate_db.py b/migrate_db.py index b409630..3d99229 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -82,6 +82,23 @@ def migrate_database(): print("Adding user_id column to work_config...") cursor.execute("ALTER TABLE work_config ADD COLUMN user_id INTEGER") + # Check if the user table exists and has the verification columns + cursor.execute("PRAGMA table_info(user)") + user_columns = [column[1] for column in cursor.fetchall()] + + # Add new columns to user table for email verification + if 'is_verified' not in user_columns: + print("Adding is_verified column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN is_verified BOOLEAN DEFAULT 0") + + if 'verification_token' not in user_columns: + print("Adding verification_token column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN verification_token VARCHAR(100)") + + if 'token_expiry' not in user_columns: + print("Adding token_expiry column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN token_expiry TIMESTAMP") + # Commit changes and close connection conn.commit() conn.close() @@ -97,13 +114,20 @@ def migrate_database(): admin = User( username='admin', email='admin@timetrack.local', - is_admin=True + is_admin=True, + is_verified=True # Admin is automatically verified ) admin.set_password('admin') # Default password, should be changed db.session.add(admin) db.session.commit() print("Created admin user with username 'admin' and password 'admin'") print("Please change the admin password after first login!") + else: + # Make sure existing admin user is verified + if not hasattr(admin, 'is_verified') or not admin.is_verified: + admin.is_verified = True + db.session.commit() + print("Marked existing admin user as verified") # Update existing time entries to associate with admin user orphan_entries = TimeEntry.query.filter_by(user_id=None).all() @@ -115,9 +139,15 @@ def migrate_database(): for config in orphan_configs: config.user_id = admin.id + # Mark all existing users as verified for backward compatibility + existing_users = User.query.filter_by(is_verified=None).all() + for user in existing_users: + user.is_verified = True + db.session.commit() print(f"Associated {len(orphan_entries)} existing time entries with admin user") print(f"Associated {len(orphan_configs)} existing work configs with admin user") + print(f"Marked {len(existing_users)} existing users as verified") if __name__ == "__main__": migrate_database() diff --git a/models.py b/models.py index aeb3d2c..6f07cdd 100644 --- a/models.py +++ b/models.py @@ -1,19 +1,26 @@ from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash -from datetime import datetime +from datetime import datetime, timedelta +import secrets db = SQLAlchemy() class User(db.Model): id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(64), unique=True, nullable=False) + username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) password_hash = db.Column(db.String(128)) is_admin = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) - # Relationship with TimeEntry - time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic') + # Email verification fields + is_verified = db.Column(db.Boolean, default=False) + verification_token = db.Column(db.String(100), unique=True, nullable=True) + token_expiry = db.Column(db.DateTime, nullable=True) + + # Relationships + time_entries = db.relationship('TimeEntry', backref='user', lazy=True) + work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False) def set_password(self, password): self.password_hash = generate_password_hash(password) @@ -21,6 +28,21 @@ class User(db.Model): def check_password(self, password): return check_password_hash(self.password_hash, password) + def generate_verification_token(self): + """Generate a verification token that expires in 24 hours""" + self.verification_token = secrets.token_urlsafe(32) + self.token_expiry = datetime.utcnow() + timedelta(hours=24) + return self.verification_token + + def verify_token(self, token): + """Verify the token and mark user as verified if valid""" + if token == self.verification_token and self.token_expiry > datetime.utcnow(): + self.is_verified = True + self.verification_token = None + self.token_expiry = None + return True + return False + def __repr__(self): return f'' diff --git a/templates/create_user.html b/templates/create_user.html index 9480822..0de9ecc 100644 --- a/templates/create_user.html +++ b/templates/create_user.html @@ -35,6 +35,13 @@ +
    + +
    +
    Cancel diff --git a/templates/layout.html b/templates/layout.html index 618f7c9..9952f3b 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -13,7 +13,6 @@
  • Home
  • {% if g.user %}
  • History
  • -
  • Config
  • About
  • @@ -22,10 +21,21 @@ Admin + {% else %} + + {% endif %} {% else %}
  • Login
  • diff --git a/templates/register.html b/templates/register.html index 367540b..53e9373 100644 --- a/templates/register.html +++ b/templates/register.html @@ -21,6 +21,7 @@
    + A verification link will be sent to this email address.
    @@ -40,6 +41,10 @@ + +
    +

    After registration, you'll need to verify your email address before you can log in.

    +
    {% endblock %} \ No newline at end of file From b2861686ea4feddac58a985d2ff344f9507f2543 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 11:00:17 +0200 Subject: [PATCH 04/14] Show Time Tracking only for logged in users. --- templates/index.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/templates/index.html b/templates/index.html index 098ba94..372a5e9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,6 +6,12 @@

    Track your work hours easily and efficiently

    +{% if not g.user %} + +Please login or register to access your dashboard. + +{% else %} +

    Time Tracking

    @@ -81,6 +87,8 @@
    +{% endif %} +

    Easy Time Tracking

    From 99765d572825eeca12a25a01c9487e430e1b83af Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 11:10:22 +0200 Subject: [PATCH 05/14] Enable blocking and unblocking of users. --- app.py | 46 +++++++++++++++++++++++++++++--------- migrate_db.py | 5 +++++ models.py | 3 +++ static/css/style.css | 19 ++++++++++++++++ templates/admin_users.html | 13 ++++++++++- 5 files changed, 75 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index f33616c..77e9565 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ from sqlalchemy import func from functools import wraps from flask_mail import Mail, Message from dotenv import load_dotenv +from werkzeug.security import check_password_hash # Load environment variables from .env file load_dotenv() @@ -96,17 +97,21 @@ def login(): user = User.query.filter_by(username=username).first() - if user is None or not user.check_password(password): + if user and user.check_password(password): # Use the check_password method + # Check if user is blocked + if user.is_blocked: + flash('Your account has been disabled. Please contact an administrator.', 'error') + return render_template('login.html') + + # Continue with normal login process + session['user_id'] = user.id + session['username'] = user.username + session['is_admin'] = user.is_admin + + flash('Login successful!', 'success') + return redirect(url_for('home')) + else: flash('Invalid username or password', 'error') - return redirect(url_for('login')) - - if not user.is_verified: - flash('Please verify your email address before logging in. Check your inbox for the verification link.', 'warning') - return redirect(url_for('login')) - - session.clear() - session['user_id'] = user.id - return redirect(url_for('home')) return render_template('login.html', title='Login') @@ -655,5 +660,26 @@ def test(): def inject_current_year(): return {'current_year': datetime.now().year} +@app.route('/admin/users/toggle-status/') +@admin_required +def toggle_user_status(user_id): + user = User.query.get_or_404(user_id) + + # Prevent blocking yourself + if user.id == session.get('user_id'): + flash('You cannot block your own account', 'error') + return redirect(url_for('admin_users')) + + # Toggle the blocked status + user.is_blocked = not user.is_blocked + db.session.commit() + + if user.is_blocked: + flash(f'User {user.username} has been blocked', 'success') + else: + flash(f'User {user.username} has been unblocked', 'success') + + return redirect(url_for('admin_users')) + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py index 3d99229..d94d51e 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -98,6 +98,11 @@ def migrate_database(): if 'token_expiry' not in user_columns: print("Adding token_expiry column to user table...") cursor.execute("ALTER TABLE user ADD COLUMN token_expiry TIMESTAMP") + + # Add is_blocked column to user table if it doesn't exist + if 'is_blocked' not in user_columns: + print("Adding is_blocked column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN is_blocked BOOLEAN DEFAULT 0") # Commit changes and close connection conn.commit() diff --git a/models.py b/models.py index 6f07cdd..8ffc96a 100644 --- a/models.py +++ b/models.py @@ -22,6 +22,9 @@ class User(db.Model): time_entries = db.relationship('TimeEntry', backref='user', lazy=True) work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False) + # New field for blocking users + is_blocked = db.Column(db.Boolean, default=False) + def set_password(self, password): self.password_hash = generate_password_hash(password) diff --git a/static/css/style.css b/static/css/style.css index a06eb35..f5a5061 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -503,4 +503,23 @@ input[type="time"]::-webkit-datetime-edit { .admin-card p { color: #666; margin-bottom: 20px; +} + +/* User status badges */ +.status-badge { + display: inline-block; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.85em; + font-weight: 500; +} + +.status-active { + background-color: #d4edda; + color: #155724; +} + +.status-blocked { + background-color: #f8d7da; + color: #721c24; } \ No newline at end of file diff --git a/templates/admin_users.html b/templates/admin_users.html index ed6cd72..df6b1f8 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -23,6 +23,7 @@ Username Email Role + Status Created Actions @@ -33,11 +34,21 @@ {{ user.username }} {{ user.email }} {% if user.is_admin %}Admin{% else %}User{% endif %} + + + {% if user.is_blocked %}Blocked{% else %}Active{% endif %} + + {{ user.created_at.strftime('%Y-%m-%d') }} Edit {% if user.id != g.user.id %} - + {% if user.is_blocked %} + Unblock + {% else %} + Block + {% endif %} + {% endif %} From ff80964956980da16e711222af58242b7d01750f Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 11:18:41 +0200 Subject: [PATCH 06/14] Remove duplicate Flash message block. --- templates/admin_dashboard.html | 10 +--------- templates/admin_users.html | 10 +--------- templates/config.html | 8 -------- templates/create_user.html | 10 +--------- templates/edit_user.html | 10 +--------- 5 files changed, 4 insertions(+), 44 deletions(-) diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 73a029c..fd44ea1 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -3,15 +3,7 @@ {% block content %}

    Admin Dashboard

    - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
    {{ message }}
    - {% endfor %} - {% endif %} - {% endwith %} - +

    User Management

    diff --git a/templates/admin_users.html b/templates/admin_users.html index df6b1f8..e06aa3d 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -3,15 +3,7 @@ {% block content %}

    User Management

    - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
    {{ message }}
    - {% endfor %} - {% endif %} - {% endwith %} - + diff --git a/templates/config.html b/templates/config.html index 76cea22..6641732 100644 --- a/templates/config.html +++ b/templates/config.html @@ -4,14 +4,6 @@

    Work Configuration

    - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
    {{ message }}
    - {% endfor %} - {% endif %} - {% endwith %} -
    diff --git a/templates/create_user.html b/templates/create_user.html index 0de9ecc..ac04403 100644 --- a/templates/create_user.html +++ b/templates/create_user.html @@ -3,15 +3,7 @@ {% block content %}

    Create New User

    - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
    {{ message }}
    - {% endfor %} - {% endif %} - {% endwith %} - +
    diff --git a/templates/edit_user.html b/templates/edit_user.html index 1a14590..da7f2c7 100644 --- a/templates/edit_user.html +++ b/templates/edit_user.html @@ -3,15 +3,7 @@ {% block content %}

    Edit User: {{ user.username }}

    - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
    {{ message }}
    - {% endfor %} - {% endif %} - {% endwith %} - +
    From e7593dc840213cea5c8ad5af3c6f6a7237db0eaa Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 11:30:34 +0200 Subject: [PATCH 07/14] Add System Settings. Enable/Disable User registration. --- app.py | 49 +++++++++++++++++++++- migrate_db.py | 37 ++++++++++++++++- models.py | 10 +++++ static/css/style.css | 74 ++++++++++++++++++++++++++++++++++ templates/admin_dashboard.html | 7 +--- templates/admin_settings.html | 31 ++++++++++++++ 6 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 templates/admin_settings.html diff --git a/app.py b/app.py index 77e9565..d4584d7 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g -from models import db, TimeEntry, WorkConfig, User +from models import db, TimeEntry, WorkConfig, User, SystemSettings import logging from datetime import datetime, time, timedelta import os @@ -42,6 +42,23 @@ mail = Mail(app) # Initialize the database with the app db.init_app(app) +# Add this function to initialize system settings +def init_system_settings(): + # Check if registration_enabled setting exists, if not create it + if not SystemSettings.query.filter_by(key='registration_enabled').first(): + registration_setting = SystemSettings( + key='registration_enabled', + value='true', + description='Controls whether new user registration is allowed' + ) + db.session.add(registration_setting) + db.session.commit() + +# Call this function during app initialization (add it where you initialize the app) +@app.before_first_request +def initialize_app(): + init_system_settings() + # Authentication decorator def login_required(f): @wraps(f) @@ -123,6 +140,14 @@ def logout(): @app.route('/register', methods=['GET', 'POST']) def register(): + # Check if registration is enabled + reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first() + registration_enabled = reg_setting and reg_setting.value == 'true' + + if not registration_enabled: + flash('Registration is currently disabled by the administrator.', 'error') + return redirect(url_for('login')) + if request.method == 'POST': username = request.form.get('username') email = request.form.get('email') @@ -681,5 +706,27 @@ def toggle_user_status(user_id): return redirect(url_for('admin_users')) +# Add this route to manage system settings +@app.route('/admin/settings', methods=['GET', 'POST']) +@admin_required +def admin_settings(): + if request.method == 'POST': + # Update registration setting + registration_enabled = 'registration_enabled' in request.form + + reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first() + if reg_setting: + reg_setting.value = 'true' if registration_enabled else 'false' + db.session.commit() + flash('System settings updated successfully!', 'success') + + # Get current settings + settings = {} + for setting in SystemSettings.query.all(): + if setting.key == 'registration_enabled': + settings['registration_enabled'] = setting.value == 'true' + + return render_template('admin_settings.html', title='System Settings', settings=settings) + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/migrate_db.py b/migrate_db.py index d94d51e..4734cb3 100644 --- a/migrate_db.py +++ b/migrate_db.py @@ -1,7 +1,7 @@ from app import app, db import sqlite3 import os -from models import User, TimeEntry, WorkConfig +from models import User, TimeEntry, WorkConfig, SystemSettings from werkzeug.security import generate_password_hash from datetime import datetime @@ -13,6 +13,9 @@ def migrate_database(): print("Database doesn't exist. Creating new database.") with app.app_context(): db.create_all() + + # Initialize system settings + init_system_settings() return print("Migrating existing database...") @@ -104,6 +107,20 @@ def migrate_database(): print("Adding is_blocked column to user table...") cursor.execute("ALTER TABLE user ADD COLUMN is_blocked BOOLEAN DEFAULT 0") + # Check if the system_settings table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='system_settings'") + if not cursor.fetchone(): + print("Creating system_settings table...") + cursor.execute(""" + CREATE TABLE system_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key VARCHAR(50) UNIQUE NOT NULL, + value VARCHAR(255) NOT NULL, + description VARCHAR(255), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + # Commit changes and close connection conn.commit() conn.close() @@ -112,6 +129,9 @@ def migrate_database(): # Create tables if they don't exist db.create_all() + # Initialize system settings + init_system_settings() + # Check if admin user exists admin = User.query.filter_by(username='admin').first() if not admin: @@ -154,6 +174,21 @@ def migrate_database(): print(f"Associated {len(orphan_configs)} existing work configs with admin user") print(f"Marked {len(existing_users)} existing users as verified") +def init_system_settings(): + """Initialize system settings with default values if they don't exist""" + # Check if registration_enabled setting exists + reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first() + if not reg_setting: + print("Adding registration_enabled system setting...") + reg_setting = SystemSettings( + key='registration_enabled', + value='true', # Default to enabled + description='Controls whether new user registration is allowed' + ) + db.session.add(reg_setting) + db.session.commit() + print("Registration setting initialized to enabled") + if __name__ == "__main__": migrate_database() print("Database migration completed") \ No newline at end of file diff --git a/models.py b/models.py index 8ffc96a..2db9d7d 100644 --- a/models.py +++ b/models.py @@ -49,6 +49,16 @@ class User(db.Model): def __repr__(self): return f'' +class SystemSettings(db.Model): + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(50), unique=True, nullable=False) + value = db.Column(db.String(255), nullable=False) + description = db.Column(db.String(255)) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + class TimeEntry(db.Model): id = db.Column(db.Integer, primary_key=True) arrival_time = db.Column(db.DateTime, nullable=False) diff --git a/static/css/style.css b/static/css/style.css index f5a5061..fd6d830 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -522,4 +522,78 @@ input[type="time"]::-webkit-datetime-edit { .status-blocked { background-color: #f8d7da; color: #721c24; +} + +.settings-card { + background-color: #f8f9fa; + border-radius: 5px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.setting-description { + color: #6c757d; + font-size: 0.9em; + margin-top: 5px; +} + +.checkbox-container { + display: block; + position: relative; + padding-left: 35px; + margin-bottom: 12px; + cursor: pointer; + font-size: 16px; + user-select: none; +} + +.checkbox-container input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.checkmark { + position: absolute; + top: 0; + left: 0; + height: 25px; + width: 25px; + background-color: #eee; + border-radius: 4px; +} + +.checkbox-container:hover input ~ .checkmark { + background-color: #ccc; +} + +.checkbox-container input:checked ~ .checkmark { + background-color: #2196F3; +} + +.checkmark:after { + content: ""; + position: absolute; + display: none; +} + +.checkbox-container input:checked ~ .checkmark:after { + display: block; +} + +.checkbox-container .checkmark:after { + left: 9px; + top: 5px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 3px 3px 0; + transform: rotate(45deg); +} + +.form-actions { + margin-top: 20px; } \ No newline at end of file diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index fd44ea1..d431a5e 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -11,14 +11,11 @@ Manage Users
    - -
    {% endblock %} \ No newline at end of file diff --git a/templates/admin_settings.html b/templates/admin_settings.html new file mode 100644 index 0000000..c076d38 --- /dev/null +++ b/templates/admin_settings.html @@ -0,0 +1,31 @@ +{% extends "layout.html" %} + +{% block content %} +
    +

    System Settings

    + + +
    +

    Registration Settings

    + +
    + +

    + When enabled, new users can register accounts. When disabled, only administrators can create new accounts. +

    +
    + + +
    + +
    + +
    + +
    +{% endblock %} \ No newline at end of file From d8ec7d636ec4cda3491a6a5f773b59980c445281 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sat, 28 Jun 2025 22:39:28 +0200 Subject: [PATCH 08/14] Add Team Roles feature. --- app.py | 279 ++++++++++++++++++++++++++++++--- migrate_roles_teams.py | 89 +++++++++++ models.py | 32 +++- repair_roles.py | 47 ++++++ static/css/style.css | 67 ++++++-- templates/admin_dashboard.html | 6 + templates/admin_teams.html | 44 ++++++ templates/admin_users.html | 2 +- templates/create_team.html | 24 +++ templates/edit_user.html | 23 +++ templates/manage_team.html | 96 ++++++++++++ 11 files changed, 672 insertions(+), 37 deletions(-) create mode 100644 migrate_roles_teams.py create mode 100644 repair_roles.py create mode 100644 templates/admin_teams.html create mode 100644 templates/create_team.html create mode 100644 templates/manage_team.html diff --git a/app.py b/app.py index d4584d7..74bfabc 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g -from models import db, TimeEntry, WorkConfig, User, SystemSettings +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role import logging from datetime import datetime, time, timedelta import os @@ -78,6 +78,38 @@ def admin_required(f): return f(*args, **kwargs) return decorated_function +# Add this decorator function after your existing decorators +def role_required(min_role): + """ + Decorator to restrict access based on user role. + min_role should be a Role enum value (e.g., Role.TEAM_LEADER) + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + return redirect(url_for('login', next=request.url)) + + # Admin always has access + if g.user.is_admin: + return f(*args, **kwargs) + + # Check role hierarchy + role_hierarchy = { + Role.TEAM_MEMBER: 1, + Role.TEAM_LEADER: 2, + Role.SUPERVISOR: 3, + Role.ADMIN: 4 + } + + if role_hierarchy.get(g.user.role, 0) < role_hierarchy.get(min_role, 0): + flash('You do not have sufficient permissions to access this page.', 'error') + return redirect(url_for('home')) + + return f(*args, **kwargs) + return decorator + return decorator + @app.before_request def load_logged_in_user(): user_id = session.get('user_id') @@ -114,21 +146,44 @@ def login(): user = User.query.filter_by(username=username).first() - if user and user.check_password(password): # Use the check_password method - # Check if user is blocked - if user.is_blocked: - flash('Your account has been disabled. Please contact an administrator.', 'error') - return render_template('login.html') + if user: + # Fix role if it's a string or None + if isinstance(user.role, str) or user.role is None: + # Map string role values to enum values + role_mapping = { + 'Team Member': Role.TEAM_MEMBER, + 'TEAM_MEMBER': Role.TEAM_MEMBER, + 'Team Leader': Role.TEAM_LEADER, + 'TEAM_LEADER': Role.TEAM_LEADER, + 'Supervisor': Role.SUPERVISOR, + 'SUPERVISOR': Role.SUPERVISOR, + 'Administrator': Role.ADMIN, + 'ADMIN': Role.ADMIN + } - # Continue with normal login process - session['user_id'] = user.id - session['username'] = user.username - session['is_admin'] = user.is_admin + if isinstance(user.role, str): + user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) + else: + user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + + db.session.commit() - flash('Login successful!', 'success') - return redirect(url_for('home')) - else: - flash('Invalid username or password', 'error') + # Now proceed with password check + if user.check_password(password): + # Check if user is blocked + if user.is_blocked: + flash('Your account has been disabled. Please contact an administrator.', 'error') + return render_template('login.html') + + # Continue with normal login process + session['user_id'] = user.id + session['username'] = user.username + session['is_admin'] = user.is_admin + + flash('Login successful!', 'success') + return redirect(url_for('home')) + + flash('Invalid username or password', 'error') return render_template('login.html', title='Login') @@ -245,7 +300,11 @@ def create_user(): email = request.form.get('email') password = request.form.get('password') is_admin = 'is_admin' in request.form - auto_verify = 'auto_verify' in request.form # New checkbox for auto verification + auto_verify = 'auto_verify' in request.form + + # Get role and team + role_name = request.form.get('role') + team_id = request.form.get('team_id') # Validate input error = None @@ -261,7 +320,21 @@ def create_user(): error = 'Email already registered' if error is None: - new_user = User(username=username, email=email, is_admin=is_admin, is_verified=auto_verify) + # Convert role string to enum + try: + role = Role[role_name] if role_name else Role.TEAM_MEMBER + except KeyError: + role = Role.TEAM_MEMBER + + # Create new user with role and team + new_user = User( + username=username, + email=email, + is_admin=is_admin, + is_verified=auto_verify, + role=role, + team_id=team_id if team_id else None + ) new_user.set_password(password) if not auto_verify: @@ -293,7 +366,11 @@ The TimeTrack Team flash(error, 'error') - return render_template('create_user.html', title='Create User') + # Get all teams for the form + teams = Team.query.all() + roles = [role for role in Role] + + return render_template('create_user.html', title='Create User', teams=teams, roles=roles) @app.route('/admin/users/edit/', methods=['GET', 'POST']) @admin_required @@ -306,6 +383,10 @@ def edit_user(user_id): password = request.form.get('password') is_admin = 'is_admin' in request.form + # Get role and team + role_name = request.form.get('role') + team_id = request.form.get('team_id') + # Validate input error = None if not username: @@ -322,6 +403,14 @@ def edit_user(user_id): user.email = email user.is_admin = is_admin + # Convert role string to enum + try: + user.role = Role[role_name] if role_name else Role.TEAM_MEMBER + except KeyError: + user.role = Role.TEAM_MEMBER + + user.team_id = team_id if team_id else None + if password: user.set_password(password) @@ -332,7 +421,11 @@ def edit_user(user_id): flash(error, 'error') - return render_template('edit_user.html', title='Edit User', user=user) + # Get all teams for the form + teams = Team.query.all() + roles = [role for role in Role] + + return render_template('edit_user.html', title='Edit User', user=user, teams=teams, roles=roles) @app.route('/admin/users/delete/', methods=['POST']) @admin_required @@ -728,5 +821,155 @@ def admin_settings(): return render_template('admin_settings.html', title='System Settings', settings=settings) +# Add these routes for team management +@app.route('/admin/teams') +@admin_required +def admin_teams(): + teams = Team.query.all() + return render_template('admin_teams.html', title='Team Management', teams=teams) + +@app.route('/admin/teams/create', methods=['GET', 'POST']) +@admin_required +def create_team(): + if request.method == 'POST': + name = request.form.get('name') + description = request.form.get('description') + + # Validate input + error = None + if not name: + error = 'Team name is required' + elif Team.query.filter_by(name=name).first(): + error = 'Team name already exists' + + if error is None: + new_team = Team(name=name, description=description) + db.session.add(new_team) + db.session.commit() + + flash(f'Team "{name}" created successfully!', 'success') + return redirect(url_for('admin_teams')) + + flash(error, 'error') + + return render_template('create_team.html', title='Create Team') + +@app.route('/admin/teams/edit/', methods=['GET', 'POST']) +@admin_required +def edit_team(team_id): + team = Team.query.get_or_404(team_id) + + if request.method == 'POST': + name = request.form.get('name') + description = request.form.get('description') + + # Validate input + error = None + if not name: + error = 'Team name is required' + elif name != team.name and Team.query.filter_by(name=name).first(): + error = 'Team name already exists' + + if error is None: + team.name = name + team.description = description + db.session.commit() + + flash(f'Team "{name}" updated successfully!', 'success') + return redirect(url_for('admin_teams')) + + flash(error, 'error') + + return render_template('edit_team.html', title='Edit Team', team=team) + +@app.route('/admin/teams/delete/', methods=['POST']) +@admin_required +def delete_team(team_id): + team = Team.query.get_or_404(team_id) + + # Check if team has members + if team.users: + flash('Cannot delete team with members. Remove all members first.', 'error') + return redirect(url_for('admin_teams')) + + team_name = team.name + db.session.delete(team) + db.session.commit() + + flash(f'Team "{team_name}" deleted successfully!', 'success') + return redirect(url_for('admin_teams')) + +@app.route('/admin/teams/', methods=['GET', 'POST']) +@admin_required +def manage_team(team_id): + team = Team.query.get_or_404(team_id) + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'update_team': + # Update team details + name = request.form.get('name') + description = request.form.get('description') + + # Validate input + error = None + if not name: + error = 'Team name is required' + elif name != team.name and Team.query.filter_by(name=name).first(): + error = 'Team name already exists' + + if error is None: + team.name = name + team.description = description + db.session.commit() + flash(f'Team "{name}" updated successfully!', 'success') + else: + flash(error, 'error') + + elif action == 'add_member': + # Add user to team + user_id = request.form.get('user_id') + if user_id: + user = User.query.get(user_id) + if user: + user.team_id = team.id + db.session.commit() + flash(f'User {user.username} added to team!', 'success') + else: + flash('User not found', 'error') + else: + flash('No user selected', 'error') + + elif action == 'remove_member': + # Remove user from team + user_id = request.form.get('user_id') + if user_id: + user = User.query.get(user_id) + if user and user.team_id == team.id: + user.team_id = None + db.session.commit() + flash(f'User {user.username} removed from team!', 'success') + else: + flash('User not found or not in this team', 'error') + else: + flash('No user selected', 'error') + + # Get team members + team_members = User.query.filter_by(team_id=team.id).all() + + # Get users not in this team for the add member form + available_users = User.query.filter( + (User.team_id != team.id) | (User.team_id == None) + ).all() + + return render_template( + 'manage_team.html', + title=f'Manage Team: {team.name}', + team=team, + team_members=team_members, + available_users=available_users + ) + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/migrate_roles_teams.py b/migrate_roles_teams.py new file mode 100644 index 0000000..7556b3a --- /dev/null +++ b/migrate_roles_teams.py @@ -0,0 +1,89 @@ +from app import app, db +from models import User, Team, Role, SystemSettings +from sqlalchemy import text +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def migrate_roles_teams(): + with app.app_context(): + logger.info("Starting migration for roles and teams...") + + # Check if the team table exists + try: + # Create the team table if it doesn't exist + db.engine.execute(text(""" + CREATE TABLE IF NOT EXISTS team ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """)) + logger.info("Team table created or already exists") + except Exception as e: + logger.error(f"Error creating team table: {e}") + return + + # Check if the user table has the role and team_id columns + try: + # Check if role column exists + result = db.engine.execute(text("PRAGMA table_info(user)")) + columns = [row[1] for row in result] + + if 'role' not in columns: + # Use the enum name instead of the value + db.engine.execute(text("ALTER TABLE user ADD COLUMN role VARCHAR(20) DEFAULT 'TEAM_MEMBER'")) + logger.info("Added role column to user table") + + if 'team_id' not in columns: + db.engine.execute(text("ALTER TABLE user ADD COLUMN team_id INTEGER REFERENCES team(id)")) + logger.info("Added team_id column to user table") + + # Create a default team for existing users + default_team = Team.query.filter_by(name="Default Team").first() + if not default_team: + default_team = Team(name="Default Team", description="Default team for existing users") + db.session.add(default_team) + db.session.commit() + logger.info("Created default team") + + # Map string role values to enum values + role_mapping = { + 'Team Member': Role.TEAM_MEMBER, + 'TEAM_MEMBER': Role.TEAM_MEMBER, + 'Team Leader': Role.TEAM_LEADER, + 'TEAM_LEADER': Role.TEAM_LEADER, + 'Supervisor': Role.SUPERVISOR, + 'SUPERVISOR': Role.SUPERVISOR, + 'Administrator': Role.ADMIN, + 'admin': Role.ADMIN, + 'ADMIN': Role.ADMIN + } + + # Assign all existing users to the default team and set role based on admin status + users = User.query.all() + for user in users: + if user.team_id is None: + user.team_id = default_team.id + + # Handle role conversion properly + if isinstance(user.role, str): + # Try to map the string to an enum value + user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) + elif user.role is None: + # Set default role based on admin status + user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + + db.session.commit() + logger.info(f"Assigned {len(users)} existing users to default team and updated roles") + + except Exception as e: + logger.error(f"Error updating user table: {e}") + return + + logger.info("Migration completed successfully") + +if __name__ == "__main__": + migrate_roles_teams() \ No newline at end of file diff --git a/models.py b/models.py index 2db9d7d..8ed7ba5 100644 --- a/models.py +++ b/models.py @@ -2,9 +2,31 @@ from flask_sqlalchemy import SQLAlchemy from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime, timedelta import secrets +import enum db = SQLAlchemy() +# Define Role as an Enum for better type safety +class Role(enum.Enum): + TEAM_MEMBER = "Team Member" + TEAM_LEADER = "Team Leader" + SUPERVISOR = "Supervisor" + ADMIN = "Administrator" # Keep existing admin role + +# Create Team model +class Team(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True) + description = db.Column(db.String(255)) + created_at = db.Column(db.DateTime, default=datetime.now) + + # Relationship with users (one team has many users) + users = db.relationship('User', backref='team', lazy=True) + + def __repr__(self): + return f'' + +# Update User model to include role and team relationship class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) @@ -18,13 +40,17 @@ class User(db.Model): verification_token = db.Column(db.String(100), unique=True, nullable=True) token_expiry = db.Column(db.DateTime, nullable=True) + # New field for blocking users + is_blocked = db.Column(db.Boolean, default=False) + + # New fields for role and team + role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER) + team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) + # Relationships time_entries = db.relationship('TimeEntry', backref='user', lazy=True) work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False) - # New field for blocking users - is_blocked = db.Column(db.Boolean, default=False) - def set_password(self, password): self.password_hash = generate_password_hash(password) diff --git a/repair_roles.py b/repair_roles.py new file mode 100644 index 0000000..01d229a --- /dev/null +++ b/repair_roles.py @@ -0,0 +1,47 @@ +from app import app, db +from models import User, Role +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def repair_user_roles(): + with app.app_context(): + logger.info("Starting user role repair...") + + # Map string role values to enum values + role_mapping = { + 'Team Member': Role.TEAM_MEMBER, + 'TEAM_MEMBER': Role.TEAM_MEMBER, + 'Team Leader': Role.TEAM_LEADER, + 'TEAM_LEADER': Role.TEAM_LEADER, + 'Supervisor': Role.SUPERVISOR, + 'SUPERVISOR': Role.SUPERVISOR, + 'Administrator': Role.ADMIN, + 'ADMIN': Role.ADMIN + } + + users = User.query.all() + fixed_count = 0 + + for user in users: + original_role = user.role + + # Fix role if it's a string or None + if isinstance(user.role, str): + user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) + fixed_count += 1 + elif user.role is None: + user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER + fixed_count += 1 + + if fixed_count > 0: + db.session.commit() + logger.info(f"Fixed roles for {fixed_count} users") + else: + logger.info("No role fixes needed") + + logger.info("Role repair completed") + +if __name__ == "__main__": + repair_user_roles() \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index fd6d830..4333e89 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -154,7 +154,7 @@ button { } .btn { - padding: 8px 16px; + padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; @@ -162,6 +162,27 @@ button { color: white; } +.btn-primary { + background-color: #45a049; + color: white; + margin-bottom: 1rem; + margin-right: 1rem; + margin-left: 1rem; + display: inline-block; + font-size: medium; +} + +.btn-sm { + padding: 5px 10px; + border-radius: 4px; + font-size: small; +} + +.btn-secondary { + background-color: #f44336; + color: white; +} + .btn:hover { background-color: #45a049; } @@ -352,20 +373,6 @@ footer { font-style: italic; } -.btn-primary { - background-color: #007bff; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - font-size: 1rem; -} - -.btn-primary:hover { - background-color: #0069d9; -} - .alert { padding: 0.75rem 1rem; margin-bottom: 1rem; @@ -474,6 +481,14 @@ input[type="time"]::-webkit-datetime-edit { } /* Admin Dashboard Styles */ +.admin-container { + padding: 1.5rem; + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-bottom: 2rem; +} + .admin-panel { display: flex; flex-wrap: wrap; @@ -596,4 +611,26 @@ input[type="time"]::-webkit-datetime-edit { .form-actions { margin-top: 20px; +} + +/* General table styling */ +.data-table { + width: 100%; + border-collapse: collapse; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.data-table th, .data-table td { + padding: 0.8rem; + text-align: left; + border-bottom: 1px solid #ddd; +} + +.data-table th { + background-color: #f2f2f2; + font-weight: bold; +} + +.data-table tr:hover { + background-color: #f5f5f5; } \ No newline at end of file diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index d431a5e..645f5a3 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -11,6 +11,12 @@ Manage Users
    +
    +

    Team Management

    +

    Configure teams and their members.

    + Configure +
    +

    System Settings

    Configure application-wide settings like registration and more.

    diff --git a/templates/admin_teams.html b/templates/admin_teams.html new file mode 100644 index 0000000..37c289f --- /dev/null +++ b/templates/admin_teams.html @@ -0,0 +1,44 @@ +{% extends 'layout.html' %} + +{% block content %} +
    +

    Team Management

    + + + + {% if teams %} + + + + + + + + + + + + {% for team in teams %} + + + + + + + + {% endfor %} + +
    NameDescriptionMembersCreatedActions
    {{ team.name }}{{ team.description }}{{ team.users|length }}{{ team.created_at.strftime('%Y-%m-%d') }} + Manage +
    + +
    +
    + + {% else %} +

    No teams found. Create a team to get started.

    + {% endif %} +
    +{% endblock %} \ No newline at end of file diff --git a/templates/admin_users.html b/templates/admin_users.html index e06aa3d..63ed340 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -9,7 +9,7 @@
    - +
    diff --git a/templates/create_team.html b/templates/create_team.html new file mode 100644 index 0000000..490efef --- /dev/null +++ b/templates/create_team.html @@ -0,0 +1,24 @@ +{% extends "layout.html" %} + +{% block content %} +
    +

    Create New Team

    + +
    +
    + + +
    + +
    + + +
    + +
    + + Cancel +
    + +
    +{% endblock %} \ No newline at end of file diff --git a/templates/edit_user.html b/templates/edit_user.html index da7f2c7..f031d30 100644 --- a/templates/edit_user.html +++ b/templates/edit_user.html @@ -20,6 +20,29 @@ +
    + + +
    + +
    + + +
    +
    Username
    + + + + + + + + + + {% for member in team_members %} + + + + + + + {% endfor %} + +
    UsernameEmailRoleActions
    {{ member.username }}{{ member.email }}{{ member.role.value }} +
    + + + +
    +
    + {% else %} +

    No members in this team yet.

    + {% endif %} +
    +
    + +
    +
    +

    Add Team Member

    +
    +
    + {% if available_users %} +
    + +
    + + +
    + +
    + {% else %} +

    No available users to add to this team.

    + {% endif %} +
    +
    + + +
    +{% endblock %} \ No newline at end of file From ecc6c1f5ac610431f505a2137d6f3bc8f875d4b6 Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sun, 29 Jun 2025 15:17:38 +0200 Subject: [PATCH 09/14] Add Dashboard for users with specific role. --- app.py | 245 ++++++++++++++++++++++++++++-- templates/dashboard.html | 309 ++++++++++++++++++++++++++++++++++++++ templates/layout.html | 20 ++- templates/team_hours.html | 184 +++++++++++++++++++++++ 4 files changed, 740 insertions(+), 18 deletions(-) create mode 100644 templates/dashboard.html create mode 100644 templates/team_hours.html diff --git a/app.py b/app.py index 74bfabc..5173bf0 100644 --- a/app.py +++ b/app.py @@ -59,6 +59,15 @@ def init_system_settings(): def initialize_app(): init_system_settings() +# Add this after initializing the app but before defining routes +@app.context_processor +def inject_globals(): + """Make certain variables available to all templates.""" + return { + 'Role': Role, + 'current_year': datetime.now().year + } + # Authentication decorator def login_required(f): @wraps(f) @@ -84,7 +93,7 @@ def role_required(min_role): Decorator to restrict access based on user role. min_role should be a Role enum value (e.g., Role.TEAM_LEADER) """ - def decorator(f): + def role_decorator(f): @wraps(f) def decorated_function(*args, **kwargs): if g.user is None: @@ -107,8 +116,8 @@ def role_required(min_role): return redirect(url_for('home')) return f(*args, **kwargs) - return decorator - return decorator + return decorated_function + return role_decorator @app.before_request def load_logged_in_user(): @@ -127,14 +136,19 @@ def load_logged_in_user(): @app.route('/') def home(): if g.user: - today = datetime.now().date() - entries = TimeEntry.query.filter( - TimeEntry.user_id == g.user.id, - TimeEntry.arrival_time >= datetime.combine(today, time.min), - TimeEntry.arrival_time <= datetime.combine(today, time.max) - ).order_by(TimeEntry.arrival_time.desc()).all() + # Get active entry (no departure time) + active_entry = TimeEntry.query.filter_by( + user_id=g.user.id, + departure_time=None + ).first() - return render_template('index.html', title='Home', entries=entries) + # Get recent completed entries for history (last 10 entries) + history = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.departure_time.isnot(None) + ).order_by(TimeEntry.arrival_time.desc()).limit(10).all() + + return render_template('index.html', title='Home', active_entry=active_entry, history=history) else: return render_template('index.html', title='Home') @@ -281,10 +295,60 @@ def verify_email(token): return redirect(url_for('login')) +@app.route('/dashboard') +@role_required(Role.TEAM_LEADER) +def dashboard(): + # Get dashboard data based on user role + dashboard_data = {} + + if g.user.is_admin or g.user.role == Role.ADMIN: + # Admin sees everything + dashboard_data.update({ + 'total_users': User.query.count(), + 'total_teams': Team.query.count(), + 'blocked_users': User.query.filter_by(is_blocked=True).count(), + 'unverified_users': User.query.filter_by(is_verified=False).count(), + 'recent_registrations': User.query.order_by(User.id.desc()).limit(5).all() + }) + + if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] or g.user.is_admin: + # Team leaders and supervisors see team-related data + if g.user.team_id or g.user.is_admin: + if g.user.is_admin: + # Admin can see all teams + teams = Team.query.all() + team_members = User.query.filter(User.team_id.isnot(None)).all() + else: + # Team leaders/supervisors see their own team + teams = [Team.query.get(g.user.team_id)] if g.user.team_id else [] + team_members = User.query.filter_by(team_id=g.user.team_id).all() if g.user.team_id else [] + + dashboard_data.update({ + 'teams': teams, + 'team_members': team_members, + 'team_member_count': len(team_members) + }) + + # Get recent time entries for the user's oversight + if g.user.is_admin: + # Admin sees all recent entries + recent_entries = TimeEntry.query.order_by(TimeEntry.arrival_time.desc()).limit(10).all() + elif g.user.team_id: + # Team leaders see their team's entries + team_user_ids = [user.id for user in User.query.filter_by(team_id=g.user.team_id).all()] + recent_entries = TimeEntry.query.filter(TimeEntry.user_id.in_(team_user_ids)).order_by(TimeEntry.arrival_time.desc()).limit(10).all() + else: + recent_entries = [] + + dashboard_data['recent_entries'] = recent_entries + + return render_template('dashboard.html', title='Dashboard', **dashboard_data) + +# Redirect old admin dashboard URL to new dashboard @app.route('/admin/dashboard') @admin_required def admin_dashboard(): - return render_template('admin_dashboard.html', title='Admin Dashboard') + return redirect(url_for('dashboard')) @app.route('/admin/users') @admin_required @@ -677,6 +741,48 @@ def update_entry(entry_id): } }) +@app.route('/team/hours') +@login_required +@role_required(Role.TEAM_LEADER) # Only team leaders and above can access +def team_hours(): + # Get the current user's team + team = Team.query.get(g.user.team_id) + + if not team: + flash('You are not assigned to any team.', 'error') + return redirect(url_for('home')) + + # Get date range from query parameters or use current week as default + today = datetime.now().date() + start_of_week = today - timedelta(days=today.weekday()) + end_of_week = start_of_week + timedelta(days=6) + + start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) + end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) + + try: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + except ValueError: + flash('Invalid date format. Using current week instead.', 'warning') + start_date = start_of_week + end_date = end_of_week + + # Generate a list of dates in the range for the table header + date_range = [] + current_date = start_date + while current_date <= end_date: + date_range.append(current_date) + current_date += timedelta(days=1) + + return render_template( + 'team_hours.html', + title=f'Team Hours', + start_date=start_date, + end_date=end_date, + date_range=date_range + ) + @app.route('/history') @login_required def history(): @@ -774,10 +880,6 @@ def internal_server_error(e): def test(): return "App is working!" -@app.context_processor -def inject_current_year(): - return {'current_year': datetime.now().year} - @app.route('/admin/users/toggle-status/') @admin_required def toggle_user_status(user_id): @@ -971,5 +1073,118 @@ def manage_team(team_id): available_users=available_users ) +@app.route('/api/team/hours_data', methods=['GET']) +@login_required +@role_required(Role.TEAM_LEADER) # Only team leaders and above can access +def team_hours_data(): + # Get the current user's team + team = Team.query.get(g.user.team_id) + + if not team: + return jsonify({ + 'success': False, + 'message': 'You are not assigned to any team.' + }), 400 + + # Get date range from query parameters or use current week as default + today = datetime.now().date() + start_of_week = today - timedelta(days=today.weekday()) + end_of_week = start_of_week + timedelta(days=6) + + start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) + end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) + include_self = request.args.get('include_self', 'false') == 'true' + + try: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + except ValueError: + return jsonify({ + 'success': False, + 'message': 'Invalid date format.' + }), 400 + + # Get all team members + team_members = User.query.filter_by(team_id=team.id).all() + + # Prepare data structure for team members' hours + team_data = [] + + for member in team_members: + # Skip if the member is the current user (team leader) and include_self is False + if member.id == g.user.id and not include_self: + continue + + # Get time entries for this member in the date range + entries = TimeEntry.query.filter( + TimeEntry.user_id == member.id, + TimeEntry.arrival_time >= datetime.combine(start_date, time.min), + TimeEntry.arrival_time <= datetime.combine(end_date, time.max) + ).order_by(TimeEntry.arrival_time).all() + + # Calculate daily and total hours + daily_hours = {} + total_seconds = 0 + + for entry in entries: + if entry.duration: # Only count completed entries + entry_date = entry.arrival_time.date() + date_str = entry_date.strftime('%Y-%m-%d') + + if date_str not in daily_hours: + daily_hours[date_str] = 0 + + daily_hours[date_str] += entry.duration + total_seconds += entry.duration + + # Convert seconds to hours for display + for date_str in daily_hours: + daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours + + total_hours = round(total_seconds / 3600, 2) # Convert to hours + + # Format entries for JSON response + formatted_entries = [] + for entry in entries: + formatted_entries.append({ + 'id': entry.id, + 'arrival_time': entry.arrival_time.strftime('%Y-%m-%d %H:%M:%S'), + 'departure_time': entry.departure_time.strftime('%Y-%m-%d %H:%M:%S') if entry.departure_time else None, + 'duration': entry.duration, + 'total_break_duration': entry.total_break_duration + }) + + # Add member data to team data + team_data.append({ + 'user': { + 'id': member.id, + 'username': member.username, + 'email': member.email + }, + 'daily_hours': daily_hours, + 'total_hours': total_hours, + 'entries': formatted_entries + }) + + # Generate a list of dates in the range for the table header + date_range = [] + current_date = start_date + while current_date <= end_date: + date_range.append(current_date.strftime('%Y-%m-%d')) + current_date += timedelta(days=1) + + return jsonify({ + 'success': True, + 'team': { + 'id': team.id, + 'name': team.name, + 'description': team.description + }, + 'team_data': team_data, + 'date_range': date_range, + 'start_date': start_date.strftime('%Y-%m-%d'), + 'end_date': end_date.strftime('%Y-%m-%d') + }) + if __name__ == '__main__': app.run(debug=True) \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..f9715eb --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,309 @@ +{% extends "layout.html" %} + +{% block content %} +
    +

    + {% if g.user.is_admin or g.user.role == Role.ADMIN %} + Admin Dashboard + {% elif g.user.role == Role.SUPERVISOR %} + Supervisor Dashboard + {% elif g.user.role == Role.TEAM_LEADER %} + Team Leader Dashboard + {% else %} + Dashboard + {% endif %} +

    + + + {% if g.user.is_admin or g.user.role == Role.ADMIN %} +
    +

    System Overview

    +
    +
    +

    {{ total_users }}

    +

    Total Users

    +
    +
    +

    {{ total_teams }}

    +

    Total Teams

    +
    +
    +

    {{ blocked_users }}

    +

    Blocked Users

    +
    +
    +

    {{ unverified_users }}

    +

    Unverified Users

    +
    +
    +
    + +
    +
    +

    User Management

    +

    Manage user accounts, permissions, and roles.

    + Manage Users +
    + +
    +

    Team Management

    +

    Configure teams and their members.

    + Manage Teams +
    + +
    +

    System Settings

    +

    Configure application-wide settings like registration and more.

    + System Settings +
    +
    + {% endif %} + + + {% if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] or g.user.is_admin %} +
    +

    Team Management

    + + {% if teams %} +
    +
    +

    {{ team_member_count }}

    +

    Team Members

    +
    +
    +

    {{ teams|length }}

    +

    Teams Managed

    +
    +
    + +
    +
    +

    Team Hours

    +

    View and monitor team member working hours.

    + View Team Hours +
    + + {% if g.user.is_admin %} +
    +

    Team Configuration

    +

    Create and manage team structures.

    + Configure Teams +
    + {% endif %} +
    + +
    +

    Your Team Members

    + {% if team_members %} +
    + {% for member in team_members %} +
    +

    {{ member.username }}

    +

    {{ member.role.value if member.role else 'Team Member' }}

    +

    {{ member.email }}

    + {% if member.is_blocked %} + Blocked + {% elif not member.is_verified %} + Unverified + {% else %} + Active + {% endif %} +
    + {% endfor %} +
    + {% else %} +

    No team members assigned yet.

    + {% endif %} +
    + {% else %} +
    +

    You are not assigned to any team. Contact your administrator to be assigned to a team.

    +
    + {% endif %} +
    + {% endif %} + + + {% if recent_entries %} +
    +

    Recent Time Entries

    +
    + + + + {% if g.user.is_admin or g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %} + + {% endif %} + + + + + + + + + {% for entry in recent_entries %} + + {% if g.user.is_admin or g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %} + + {% endif %} + + + + + + + {% endfor %} + +
    UserDateArrivalDepartureDurationStatus
    {{ entry.user.username }}{{ entry.arrival_time.strftime('%Y-%m-%d') }}{{ entry.arrival_time.strftime('%H:%M:%S') }}{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }} + {% if entry.duration %} + {{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) }} + {% else %} + In progress + {% endif %} + + {% if not entry.departure_time %} + Active + {% else %} + Completed + {% endif %} +
    +
    +
    + {% endif %} + + +
    +

    Quick Actions

    +
    +
    +

    My Profile

    +

    Update your personal information and password.

    + Edit Profile +
    + +
    +

    Configuration

    +

    Configure work hours and break settings.

    + Work Config +
    + +
    +

    Time History

    +

    View your complete time tracking history.

    + View History +
    +
    +
    +
    + + +{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html index 9952f3b..569bad3 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -15,19 +15,33 @@
  • History
  • About
  • - + {% if g.user.is_admin %} + {% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %} + + {% else %} - +

    Update Profile

    @@ -45,5 +52,107 @@
    + +
    +

    Security Settings

    + +
    +

    Two-Factor Authentication

    + {% if user.two_factor_enabled %} +

    Two-factor authentication is enabled for your account. This adds an extra layer of security by requiring a code from your authenticator app when logging in.

    + +
    +
    + + +
    + +
    + {% else %} +

    Two-factor authentication is not enabled for your account. We strongly recommend enabling it to protect your account.

    +

    With 2FA enabled, you'll need both your password and a code from your phone to log in.

    + + Enable Two-Factor Authentication + {% endif %} +
    +
    + + {% endblock %} \ No newline at end of file diff --git a/templates/setup_2fa.html b/templates/setup_2fa.html new file mode 100644 index 0000000..e8b2791 --- /dev/null +++ b/templates/setup_2fa.html @@ -0,0 +1,266 @@ +{% extends "layout.html" %} + +{% block content %} +
    +

    Setup Two-Factor Authentication

    + +
    +
    +

    Step 1: Install an Authenticator App

    +

    Download and install an authenticator app on your mobile device:

    +
      +
    • Google Authenticator (iOS/Android)
    • +
    • Microsoft Authenticator (iOS/Android)
    • +
    • Authy (iOS/Android/Desktop)
    • +
    • 1Password (Premium feature)
    • +
    +
    + +
    +

    Step 2: Scan QR Code or Enter Secret

    +
    +
    + 2FA QR Code +
    +
    +

    Can't scan? Enter this code manually:

    +
    {{ secret }}
    +

    Account: {{ g.user.email }}
    Issuer: TimeTrack

    +
    +
    +
    + +
    +

    Step 3: Verify Setup

    +

    Enter the 6-digit code from your authenticator app to complete setup:

    + +
    +
    + + + Enter the 6-digit code from your authenticator app +
    + +
    + + Cancel +
    +
    +
    +
    + +
    +

    πŸ” Security Notice

    +

    Important: Once enabled, you'll need your authenticator app to log in. Make sure to:

    +
      +
    • Keep your authenticator app secure and backed up
    • +
    • Store the secret code in a safe place as a backup
    • +
    • Remember your password - you'll need both your password and 2FA code to log in
    • +
    +
    +
    + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/verify_2fa.html b/templates/verify_2fa.html new file mode 100644 index 0000000..354558b --- /dev/null +++ b/templates/verify_2fa.html @@ -0,0 +1,182 @@ +{% extends "layout.html" %} + +{% block content %} +
    +
    +

    Two-Factor Authentication

    +

    Please enter the 6-digit code from your authenticator app to complete login.

    + +
    +
    + + + Enter the 6-digit code from your authenticator app +
    + +
    + +
    +
    + +
    +

    Having trouble? Make sure your device's time is synchronized and try a new code.

    +

    ← Back to Login

    +
    +
    +
    + + + + +{% endblock %} \ No newline at end of file From 68db55104187eb6b5333b8af9b61fd5f97052c3d Mon Sep 17 00:00:00 2001 From: Jens Luedicke Date: Sun, 29 Jun 2025 15:47:07 +0200 Subject: [PATCH 14/14] Update Profile page. --- templates/profile.html | 133 +++++++++++++++++++++++++++++++++-------- 1 file changed, 107 insertions(+), 26 deletions(-) diff --git a/templates/profile.html b/templates/profile.html index 82f2b5f..6bcfe42 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -25,33 +25,53 @@

    -

    Update Profile

    -
    -
    - - -
    - +

    Profile Settings

    + +
    +

    Basic Information

    + +
    + + + This email address is used for account verification and notifications. +
    + +
    + +
    + +
    + +

    Change Password

    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - -
    - +

    Update your account password to keep your account secure.

    +
    + + + +
    + + + Enter your current password to verify your identity. +
    + +
    + + + Choose a strong password with at least 8 characters. +
    + +
    + + + Re-enter your new password to confirm. +
    + +
    + +
    +
    +

    Security Settings

    @@ -89,6 +109,24 @@ font-weight: bold; } +.profile-card { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 0.5rem; + padding: 1.5rem; + margin: 1.5rem 0; +} + +.profile-card h3 { + color: #007bff; + margin-bottom: 1rem; +} + +.profile-card p { + color: #6c757d; + margin-bottom: 1.5rem; +} + .security-section { margin-top: 2rem; padding-top: 2rem; @@ -154,5 +192,48 @@ .btn-primary:hover { background: #0056b3; } + +.btn-warning { + background: #ffc107; + color: #212529; +} + +.btn-warning:hover { + background: #e0a800; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; +} + +.form-control { + width: 100%; + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: 0.25rem; + font-size: 1rem; + line-height: 1.5; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.form-control:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.form-group small { + display: block; + margin-top: 0.25rem; + color: #6c757d; + font-size: 0.875rem; +} {% endblock %} \ No newline at end of file