diff --git a/app.py b/app.py index 0a0f931..e23c4e6 100644 --- a/app.py +++ b/app.py @@ -1,57 +1,687 @@ -from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, send_file, Response -from models import db, TimeEntry, WorkConfig +from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g +from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role +import logging +from datetime import datetime, time, timedelta import os from sqlalchemy import func -import csv -import io -import pandas as pd -from datetime import datetime, time, timedelta +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() + +# Configure logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) 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 + +# 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) +# 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() + +# 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) + def decorated_function(*args, **kwargs): + if g.user is None: + 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 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 + +# 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 role_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 decorated_function + return role_decorator + +@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) + 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(): - # Get active time entry (if any) - active_entry = TimeEntry.query.filter_by(departure_time=None).first() + if g.user: + # Get active entry (no departure time) + active_entry = TimeEntry.query.filter_by( + user_id=g.user.id, + departure_time=None + ).first() + + # Get today's completed entries for history + today = datetime.now().date() + history = TimeEntry.query.filter( + TimeEntry.user_id == g.user.id, + TimeEntry.departure_time.isnot(None), + 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=history) + else: + return render_template('index.html', title='Home') - # Get today's date - today = datetime.now().date() +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + + user = User.query.filter_by(username=username).first() + + 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 + } + + 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() + + # 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') + + # Check if 2FA is enabled + if user.two_factor_enabled: + # Store user ID for 2FA verification + session['2fa_user_id'] = user.id + return redirect(url_for('verify_2fa')) + else: + # 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') - # Get time entries for today only - today_start = datetime.combine(today, time.min) - today_end = datetime.combine(today, time.max) +@app.route('/logout') +def logout(): + session.clear() + flash('You have been logged out.', 'info') + return redirect(url_for('login')) - today_entries = TimeEntry.query.filter( - TimeEntry.arrival_time >= today_start, - TimeEntry.arrival_time <= today_end - ).order_by(TimeEntry.arrival_time.desc()).all() +@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') + 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: + 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}, - return render_template('index.html', title='Home', active_entry=active_entry, history=today_entries) +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('/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 redirect(url_for('dashboard')) + +@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 + 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 + 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: + # 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: + # 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() + + 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') + + # 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 +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 + + # Get role and team + role_name = request.form.get('role') + team_id = request.form.get('team_id') + + # 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 + + # 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) + + db.session.commit() + + flash(f'User {username} updated successfully!', 'success') + return redirect(url_for('admin_users')) + + flash(error, 'error') + + # 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 +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('/2fa/setup', methods=['GET', 'POST']) +@login_required +def setup_2fa(): + if request.method == 'POST': + # Verify the TOTP code before enabling 2FA + totp_code = request.form.get('totp_code') + + if not totp_code: + flash('Please enter the verification code from your authenticator app.', 'error') + return redirect(url_for('setup_2fa')) + + try: + if g.user.verify_2fa_token(totp_code, allow_setup=True): + g.user.two_factor_enabled = True + db.session.commit() + flash('Two-factor authentication has been successfully enabled!', 'success') + return redirect(url_for('profile')) + else: + flash('Invalid verification code. Please make sure your device time is synchronized and try again.', 'error') + return redirect(url_for('setup_2fa')) + except Exception as e: + logger.error(f"2FA setup error: {str(e)}") + flash('An error occurred during 2FA setup. Please try again.', 'error') + return redirect(url_for('setup_2fa')) + + # GET request - show setup page + if g.user.two_factor_enabled: + flash('Two-factor authentication is already enabled.', 'info') + return redirect(url_for('profile')) + + # Generate secret if not exists + if not g.user.two_factor_secret: + g.user.generate_2fa_secret() + db.session.commit() + + # Generate QR code + import qrcode + import io + import base64 + + qr_uri = g.user.get_2fa_uri() + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(qr_uri) + qr.make(fit=True) + + # Create QR code image + qr_img = qr.make_image(fill_color="black", back_color="white") + img_buffer = io.BytesIO() + qr_img.save(img_buffer, format='PNG') + img_buffer.seek(0) + qr_code_b64 = base64.b64encode(img_buffer.getvalue()).decode() + + return render_template('setup_2fa.html', + title='Setup Two-Factor Authentication', + secret=g.user.two_factor_secret, + qr_code=qr_code_b64) + +@app.route('/2fa/disable', methods=['POST']) +@login_required +def disable_2fa(): + password = request.form.get('password') + + if not password or not g.user.check_password(password): + flash('Please enter your correct password to disable 2FA.', 'error') + return redirect(url_for('profile')) + + g.user.two_factor_enabled = False + g.user.two_factor_secret = None + db.session.commit() + + flash('Two-factor authentication has been disabled.', 'success') + return redirect(url_for('profile')) + +@app.route('/2fa/verify', methods=['GET', 'POST']) +def verify_2fa(): + # Check if user is in 2FA verification state + user_id = session.get('2fa_user_id') + if not user_id: + return redirect(url_for('login')) + + user = User.query.get(user_id) + if not user or not user.two_factor_enabled: + session.pop('2fa_user_id', None) + return redirect(url_for('login')) + + if request.method == 'POST': + totp_code = request.form.get('totp_code') + + if user.verify_2fa_token(totp_code): + # Complete login process + session.pop('2fa_user_id', None) + 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 verification code. Please try again.', 'error') + + return render_template('verify_2fa.html', title='Two-Factor Authentication') @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() @@ -61,9 +691,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() @@ -96,9 +727,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() @@ -127,6 +759,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() @@ -161,21 +794,28 @@ 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 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: @@ -212,10 +852,53 @@ 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(): - # 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) @@ -266,12 +949,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, @@ -295,6 +979,324 @@ 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.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')) + +# 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) + +# 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 + ) + +@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') + }) +======= @app.route('/export') def export(): return render_template('export.html', title='Export Data') @@ -397,6 +1399,6 @@ def download_export(): as_attachment=True, download_name=f"{filename}.xlsx" ) - + 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..703f85a 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, SystemSettings, Team, Role +from werkzeug.security import generate_password_hash +from datetime import datetime def migrate_database(): db_path = 'timetrack.db' @@ -10,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...") @@ -34,6 +40,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 +57,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 +79,172 @@ 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") + + # 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") + + # 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") + + # Add role column to user table if it doesn't exist + if 'role' not in user_columns: + print("Adding role column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN role VARCHAR(50) DEFAULT 'Team Member'") + + # Add team_id column to user table if it doesn't exist + if 'team_id' not in user_columns: + print("Adding team_id column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN team_id INTEGER") + + # Add 2FA columns to user table if they don't exist + if 'two_factor_enabled' not in user_columns: + print("Adding two_factor_enabled column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN two_factor_enabled BOOLEAN DEFAULT 0") + + if 'two_factor_secret' not in user_columns: + print("Adding two_factor_secret column to user table...") + cursor.execute("ALTER TABLE user ADD COLUMN two_factor_secret VARCHAR(32)") + + # Check if the team table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='team'") + if not cursor.fetchone(): + print("Creating team table...") + cursor.execute(""" + CREATE TABLE team ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) UNIQUE NOT NULL, + description VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # 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() - print("Database migration completed successfully!") + with app.app_context(): + # 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: + # Create admin user + admin = User( + username='admin', + email='admin@timetrack.local', + is_admin=True, + is_verified=True, # Admin is automatically verified + role=Role.ADMIN, + two_factor_enabled=False + ) + 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 and has correct role + if not hasattr(admin, 'is_verified') or not admin.is_verified: + admin.is_verified = True + if not hasattr(admin, 'role') or admin.role is None: + admin.role = Role.ADMIN + if not hasattr(admin, 'two_factor_enabled') or admin.two_factor_enabled is None: + admin.two_factor_enabled = False + db.session.commit() + print("Updated existing admin user with new fields") + + # 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 + + # 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 + + # Update existing users with default role and 2FA settings + users_to_update = User.query.all() + updated_count = 0 + for user in users_to_update: + updated = False + if not hasattr(user, 'role') or user.role is None: + if user.is_admin: + user.role = Role.ADMIN + else: + user.role = Role.TEAM_MEMBER + updated = True + if not hasattr(user, 'two_factor_enabled') or user.two_factor_enabled is None: + user.two_factor_enabled = False + updated = True + if updated: + updated_count += 1 + + 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") + print(f"Updated {updated_count} users with default role and 2FA settings") + +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() \ No newline at end of file + migrate_database() + print("Database migration completed") \ 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 1e92c1b..bdebfda 100644 --- a/models.py +++ b/models.py @@ -1,8 +1,122 @@ from flask_sqlalchemy import SQLAlchemy -from datetime import datetime +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) + 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) + + # 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) + + # 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) + + # Two-Factor Authentication fields + two_factor_enabled = db.Column(db.Boolean, default=False) + two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret + + # 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) + + 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 generate_2fa_secret(self): + """Generate a new 2FA secret""" + import pyotp + self.two_factor_secret = pyotp.random_base32() + return self.two_factor_secret + + def get_2fa_uri(self): + """Get the provisioning URI for QR code generation""" + if not self.two_factor_secret: + return None + import pyotp + totp = pyotp.TOTP(self.two_factor_secret) + return totp.provisioning_uri( + name=self.email, + issuer_name="TimeTrack" + ) + + def verify_2fa_token(self, token, allow_setup=False): + """Verify a 2FA token""" + if not self.two_factor_secret: + return False + # During setup, allow verification even if 2FA isn't enabled yet + if not allow_setup and not self.two_factor_enabled: + return False + import pyotp + totp = pyotp.TOTP(self.two_factor_secret) + return totp.verify(token, valid_window=1) # Allow 1 window tolerance + + 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) @@ -11,6 +125,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 +139,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/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/requirements.txt b/requirements.txt index 55af582..ad8c5a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,5 @@ click==8.0.1 Flask-SQLAlchemy==2.5.1 SQLAlchemy==1.4.23 python-dotenv==0.19.0 -pandas -xlsxwriter \ No newline at end of file +pyotp==2.6.0 +qrcode[pil]==7.3.1 diff --git a/static/css/style.css b/static/css/style.css index adb7890..125ce04 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; @@ -97,11 +155,7 @@ button { } .btn { - display: inline-block; - background-color: #4CAF50; - color: white; - padding: 0.5rem 1rem; - text-decoration: none; + padding: 5px 10px; border: none; border-radius: 4px; cursor: pointer; @@ -109,6 +163,27 @@ button { text-align: center; } +.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; } @@ -299,20 +374,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; @@ -420,6 +481,234 @@ input[type="time"]::-webkit-datetime-edit { font-style: italic; } +/* 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; + 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; +} + +/* 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; +} + +.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; +} + +/* 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; +} + +/* Team Hours Page Styles */ +.date-filter { + margin-bottom: 20px; + padding: 15px; + background-color: #f5f5f5; + border-radius: 5px; +} + +.date-filter .form-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.team-hours-table { + margin-bottom: 30px; + overflow-x: auto; +} + +.team-hours-table table { + width: 100%; + border-collapse: collapse; +} + +.team-hours-table th, +.team-hours-table td { + padding: 8px 12px; + border: 1px solid #ddd; + text-align: center; +} + +.team-hours-table th { + background-color: #f0f0f0; +} + +.team-hours-details { + margin-top: 30px; +} + +.member-entries { + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 1px solid #eee; +} + +.member-entries h4 { + margin-bottom: 10px; + color: #333; +} + +.member-entries table { + width: 100%; + border-collapse: collapse; +} + +.member-entries th, +.member-entries td { + padding: 6px 10px; + border: 1px solid #ddd; + text-align: left; +} + +.member-entries th { + background-color: #f5f5f5; +} + +.checkbox { + display: flex; + align-items: center; + gap: 5px; +} +======= .export-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); diff --git a/static/js/script.js b/static/js/script.js index 1fb14b9..51ba52f 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'); + + // 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 dropdowns when clicking outside + document.addEventListener('click', function(e) { + if (!e.target.closest('.dropdown')) { + document.querySelectorAll('.dropdown-menu').forEach(menu => { + menu.style.display = 'none'; + }); + } + }); }); // 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/about.html b/templates/about.html index 5edbb20..c9b362d 100644 --- a/templates/about.html +++ b/templates/about.html @@ -2,8 +2,230 @@ {% block content %}
-

About Us

-

This is a simple Flask web application created as a demonstration.

-

Learn more about our team and mission here.

+

About TimeTrack

+ +
+
+

Professional Time Tracking Made Simple

+

TimeTrack is a comprehensive time management solution designed to help organizations and individuals monitor work hours efficiently. Built with simplicity and accuracy in mind, our platform provides the tools you need to track, manage, and analyze time spent on work activities.

+
+ +
+

Key Features

+ +
+
+

🕐 Precise Time Tracking

+

Track your work hours with precision. Simply click "Arrive" when you start working and "Leave" when you're done. Our system automatically calculates your work duration.

+
+ +
+

⏸️ Smart Break Management

+

Use the pause feature to track breaks accurately. The system automatically deducts break time from your total work hours and ensures compliance with mandatory break policies.

+
+ +
+

👥 Team Management

+

Managers can organize users into teams, monitor team performance, and track collective working hours. Role-based access ensures appropriate permissions for different user levels.

+
+ +
+

📊 Comprehensive Reporting

+

View detailed time entry history, team performance metrics, and individual productivity reports. Export data for payroll and project management purposes.

+
+ +
+

🔧 Flexible Configuration

+

Customize work hour requirements, mandatory break durations, and threshold settings to match your organization's policies and labor regulations.

+
+ +
+

🔐 Secure & Reliable

+

Built with security best practices, user authentication, email verification, and role-based access control to protect your organization's data.

+
+
+
+ +
+

User Roles & Permissions

+ +
+
+

👤 Team Member

+

Track personal work hours, manage breaks, view individual history, and update profile settings.

+
+ +
+

👨‍💼 Team Leader

+

All team member features plus monitor team hours, view team member performance, and access team management tools.

+
+ +
+

👩‍💼 Supervisor

+

Enhanced oversight capabilities with access to multiple teams and comprehensive reporting across supervised groups.

+
+ +
+

⚙️ Administrator

+

Full system access including user management, team configuration, system settings, and complete administrative control.

+
+
+
+ +
+

Why Choose TimeTrack?

+ +
+
+

✅ Compliance Ready

+

Automatically enforces break policies and work hour regulations to help your organization stay compliant with labor laws.

+
+ +
+

✅ Easy to Use

+

Intuitive interface requires minimal training. Start tracking time immediately without complicated setup procedures.

+
+ +
+

✅ Scalable Solution

+

Grows with your organization from small teams to large enterprises. Role-based architecture supports complex organizational structures.

+
+ +
+

✅ Data-Driven Insights

+

Generate meaningful reports and analytics to optimize productivity, identify trends, and make informed business decisions.

+
+
+
+ +
+

Technical Information

+

TimeTrack is built using modern web technologies including Flask (Python), SQLite database, and responsive HTML/CSS/JavaScript frontend. The application features a REST API architecture, secure session management, and email integration for user verification.

+
+ +
+

Getting Started

+

Ready to start tracking your time more effectively? Create an account to begin, or log in if you already have access. For questions or support, contact your system administrator.

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

Admin Dashboard

+ +
+
+

User Management

+

Manage user accounts, permissions, and roles.

+ Manage Users +
+ +
+

Team Management

+

Configure teams and their members.

+ Configure +
+ +
+

System Settings

+

Configure application-wide settings like registration and more.

+ Configure +
+
+
+{% 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 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 new file mode 100644 index 0000000..63ed340 --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,91 @@ +{% extends "layout.html" %} + +{% block content %} +
+

User Management

+ + + +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
UsernameEmailRoleStatusCreatedActions
{{ 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 %} +
+
+ + + + + +
+{% endblock %} \ No newline at end of file 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_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/create_user.html b/templates/create_user.html new file mode 100644 index 0000000..ac04403 --- /dev/null +++ b/templates/create_user.html @@ -0,0 +1,43 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Create New User

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ + Cancel +
+
+
+{% endblock %} \ 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/edit_user.html b/templates/edit_user.html new file mode 100644 index 0000000..f031d30 --- /dev/null +++ b/templates/edit_user.html @@ -0,0 +1,59 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Edit User: {{ user.username }}

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + Cancel +
+
+
+{% endblock %} \ No newline at end of file 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

diff --git a/templates/layout.html b/templates/layout.html index e9422de..569bad3 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -3,7 +3,7 @@ - TimeTrack - {{ title }} + {{ title }} - TimeTrack @@ -11,20 +11,70 @@
+ {% 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/manage_team.html b/templates/manage_team.html new file mode 100644 index 0000000..7f06ceb --- /dev/null +++ b/templates/manage_team.html @@ -0,0 +1,96 @@ +{% extends 'layout.html' %} + +{% block content %} +
+

Manage Team: {{ team.name }}

+ +
+
+

Team Details

+
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+ +
+
+

Team Members

+
+
+ {% if team_members %} + + + + + + + + + + + {% 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 diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..6bcfe42 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,239 @@ +{% 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') }}

+

Two-Factor Authentication: + {% if user.two_factor_enabled %} + ✅ Enabled + {% else %} + ❌ Disabled + {% endif %} +

+
+ +

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

+ +
+

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/register.html b/templates/register.html new file mode 100644 index 0000000..53e9373 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,50 @@ +{% 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 %} + +
+
+ + +
+ +
+ + + A verification link will be sent to this email address. +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+

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

+
+
+
+{% 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/team_hours.html b/templates/team_hours.html new file mode 100644 index 0000000..3ecf485 --- /dev/null +++ b/templates/team_hours.html @@ -0,0 +1,184 @@ +{% extends "layout.html" %} + +{% block content %} +
+

Team Hours

+ +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
Loading team data...
+ + + + + + + +
+ + +
+ + +{% endblock %} 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