Merge pull request #1 from nullmedium/feature-user-management
Feature user management
This commit is contained in:
178
migrate_db.py
178
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()
|
||||
migrate_database()
|
||||
print("Database migration completed")
|
||||
89
migrate_roles_teams.py
Normal file
89
migrate_roles_teams.py
Normal file
@@ -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()
|
||||
118
models.py
118
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'<Team {self.name}>'
|
||||
|
||||
# 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'<User {self.username}>'
|
||||
|
||||
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'<SystemSettings {self.key}={self.value}>'
|
||||
|
||||
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'<TimeEntry {self.id}: {self.arrival_time} - {self.departure_time}>'
|
||||
@@ -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'<WorkConfig {self.id}: {self.work_hours_per_day}h/day, {self.mandatory_break_minutes}min break>'
|
||||
47
repair_roles.py
Normal file
47
repair_roles.py
Normal file
@@ -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()
|
||||
@@ -7,5 +7,5 @@ click==8.0.1
|
||||
Flask-SQLAlchemy==2.5.1
|
||||
SQLAlchemy==1.4.23
|
||||
python-dotenv==0.19.0
|
||||
pandas
|
||||
xlsxwriter
|
||||
pyotp==2.6.0
|
||||
qrcode[pil]==7.3.1
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
9
templates/404.html
Normal file
9
templates/404.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you are looking for does not exist.</p>
|
||||
<a href="{{ url_for('home') }}" class="btn">Return to Home</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
templates/500.html
Normal file
0
templates/500.html
Normal file
@@ -2,8 +2,230 @@
|
||||
|
||||
{% block content %}
|
||||
<section class="about">
|
||||
<h1>About Us</h1>
|
||||
<p>This is a simple Flask web application created as a demonstration.</p>
|
||||
<p>Learn more about our team and mission here.</p>
|
||||
<h1>About TimeTrack</h1>
|
||||
|
||||
<div class="about-content">
|
||||
<div class="intro">
|
||||
<h2>Professional Time Tracking Made Simple</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-section">
|
||||
<h2>Key Features</h2>
|
||||
|
||||
<div class="feature-grid">
|
||||
<div class="feature-item">
|
||||
<h3>🕐 Precise Time Tracking</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<h3>⏸️ Smart Break Management</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<h3>👥 Team Management</h3>
|
||||
<p>Managers can organize users into teams, monitor team performance, and track collective working hours. Role-based access ensures appropriate permissions for different user levels.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<h3>📊 Comprehensive Reporting</h3>
|
||||
<p>View detailed time entry history, team performance metrics, and individual productivity reports. Export data for payroll and project management purposes.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<h3>🔧 Flexible Configuration</h3>
|
||||
<p>Customize work hour requirements, mandatory break durations, and threshold settings to match your organization's policies and labor regulations.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
<h3>🔐 Secure & Reliable</h3>
|
||||
<p>Built with security best practices, user authentication, email verification, and role-based access control to protect your organization's data.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roles-section">
|
||||
<h2>User Roles & Permissions</h2>
|
||||
|
||||
<div class="role-cards">
|
||||
<div class="role-card">
|
||||
<h3>👤 Team Member</h3>
|
||||
<p>Track personal work hours, manage breaks, view individual history, and update profile settings.</p>
|
||||
</div>
|
||||
|
||||
<div class="role-card">
|
||||
<h3>👨💼 Team Leader</h3>
|
||||
<p>All team member features plus monitor team hours, view team member performance, and access team management tools.</p>
|
||||
</div>
|
||||
|
||||
<div class="role-card">
|
||||
<h3>👩💼 Supervisor</h3>
|
||||
<p>Enhanced oversight capabilities with access to multiple teams and comprehensive reporting across supervised groups.</p>
|
||||
</div>
|
||||
|
||||
<div class="role-card">
|
||||
<h3>⚙️ Administrator</h3>
|
||||
<p>Full system access including user management, team configuration, system settings, and complete administrative control.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="benefits-section">
|
||||
<h2>Why Choose TimeTrack?</h2>
|
||||
|
||||
<div class="benefits-list">
|
||||
<div class="benefit">
|
||||
<h3>✅ Compliance Ready</h3>
|
||||
<p>Automatically enforces break policies and work hour regulations to help your organization stay compliant with labor laws.</p>
|
||||
</div>
|
||||
|
||||
<div class="benefit">
|
||||
<h3>✅ Easy to Use</h3>
|
||||
<p>Intuitive interface requires minimal training. Start tracking time immediately without complicated setup procedures.</p>
|
||||
</div>
|
||||
|
||||
<div class="benefit">
|
||||
<h3>✅ Scalable Solution</h3>
|
||||
<p>Grows with your organization from small teams to large enterprises. Role-based architecture supports complex organizational structures.</p>
|
||||
</div>
|
||||
|
||||
<div class="benefit">
|
||||
<h3>✅ Data-Driven Insights</h3>
|
||||
<p>Generate meaningful reports and analytics to optimize productivity, identify trends, and make informed business decisions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-section">
|
||||
<h2>Technical Information</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="support-section">
|
||||
<h2>Getting Started</h2>
|
||||
<p>Ready to start tracking your time more effectively? <a href="{{ url_for('register') }}">Create an account</a> to begin, or <a href="{{ url_for('login') }}">log in</a> if you already have access. For questions or support, contact your system administrator.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.about-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.intro {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
padding: 2rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.intro h2 {
|
||||
color: #007bff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.features-section, .roles-section, .benefits-section, .technical-section, .support-section {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.feature-item h3 {
|
||||
color: #007bff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.role-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.role-card h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.benefit {
|
||||
background: #e8f5e8;
|
||||
border-left: 4px solid #28a745;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.benefit h3 {
|
||||
color: #155724;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.technical-section {
|
||||
background: #f8f9fa;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 4px solid #6c757d;
|
||||
}
|
||||
|
||||
.support-section {
|
||||
text-align: center;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.support-section a {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.support-section a:hover {
|
||||
color: #cceeff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.feature-grid, .role-cards, .benefits-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.benefits-list .benefit {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
27
templates/admin_dashboard.html
Normal file
27
templates/admin_dashboard.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
<div class="admin-panel">
|
||||
<div class="admin-card">
|
||||
<h2>User Management</h2>
|
||||
<p>Manage user accounts, permissions, and roles.</p>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-primary">Manage Users</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>Team Management</h2>
|
||||
<p>Configure teams and their members.</p>
|
||||
<a href="{{ url_for('admin_teams') }}" class="btn btn-primary">Configure</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>System Settings</h2>
|
||||
<p>Configure application-wide settings like registration and more.</p>
|
||||
<a href="{{ url_for('admin_settings') }}" class="btn btn-primary">Configure</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
31
templates/admin_settings.html
Normal file
31
templates/admin_settings.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<h1>System Settings</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('admin_settings') }}">
|
||||
<div class="settings-card">
|
||||
<h2>Registration Settings</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" name="registration_enabled"
|
||||
{% if settings.registration_enabled %}checked{% endif %}>
|
||||
<span class="checkmark"></span>
|
||||
Enable User Registration
|
||||
</label>
|
||||
<p class="setting-description">
|
||||
When enabled, new users can register accounts. When disabled, only administrators can create new accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- You can add more settings here in the future -->
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
templates/admin_teams.html
Normal file
44
templates/admin_teams.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Team Management</h1>
|
||||
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('create_team') }}" class="btn btn-primary">Create New Team</a>
|
||||
</div>
|
||||
|
||||
{% if teams %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Members</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for team in teams %}
|
||||
<tr>
|
||||
<td>{{ team.name }}</td>
|
||||
<td>{{ team.description }}</td>
|
||||
<td>{{ team.users|length }}</td>
|
||||
<td>{{ team.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('manage_team', team_id=team.id) }}" class="button btn btn-sm btn-info">Manage</a>
|
||||
<form method="POST" action="{{ url_for('delete_team', team_id=team.id) }}" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this team?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% else %}
|
||||
<p>No teams found. Create a team to get started.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
91
templates/admin_users.html
Normal file
91
templates/admin_users.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<h1>User Management</h1>
|
||||
|
||||
<div class="admin-actions">
|
||||
<a href="{{ url_for('create_user') }}" class="btn btn-success">Create New User</a>
|
||||
</div>
|
||||
|
||||
<div class="user-list">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>{% if user.is_admin %}Admin{% else %}User{% endif %}</td>
|
||||
<td>
|
||||
<span class="status-badge {% if user.is_blocked %}status-blocked{% else %}status-active{% endif %}">
|
||||
{% if user.is_blocked %}Blocked{% else %}Active{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('edit_user', user_id=user.id) }}" class="btn btn-sm btn-primary">Edit</a>
|
||||
{% if user.id != g.user.id %}
|
||||
{% if user.is_blocked %}
|
||||
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-success">Unblock</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('toggle_user_status', user_id=user.id) }}" class="btn btn-sm btn-warning">Block</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-danger" onclick="confirmDelete({{ user.id }}, '{{ user.username }}')">Delete</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="delete-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Confirm Deletion</h2>
|
||||
<p>Are you sure you want to delete user <span id="delete-username"></span>?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
<form id="delete-form" method="POST">
|
||||
<button type="button" id="cancel-delete" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDelete(userId, username) {
|
||||
document.getElementById('delete-username').textContent = username;
|
||||
document.getElementById('delete-form').action = "{{ url_for('delete_user', user_id=0) }}".replace('0', userId);
|
||||
document.getElementById('delete-modal').style.display = 'block';
|
||||
}
|
||||
|
||||
// Close modal when clicking the X
|
||||
document.querySelector('.close').addEventListener('click', function() {
|
||||
document.getElementById('delete-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
// Close modal when clicking Cancel
|
||||
document.getElementById('cancel-delete').addEventListener('click', function() {
|
||||
document.getElementById('delete-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target == document.getElementById('delete-modal')) {
|
||||
document.getElementById('delete-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,14 +4,6 @@
|
||||
<div class="config-container">
|
||||
<h1>Work Configuration</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('config') }}" class="config-form">
|
||||
<div class="form-group">
|
||||
<label for="work_hours_per_day">Work Hours Per Day:</label>
|
||||
|
||||
24
templates/create_team.html
Normal file
24
templates/create_team.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<h1>Create New Team</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('create_team') }}" class="team-form">
|
||||
<div class="form-group">
|
||||
<label for="name">Team Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Create Team</button>
|
||||
<a href="{{ url_for('admin_teams') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
43
templates/create_user.html
Normal file
43
templates/create_user.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<h1>Create New User</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('create_user') }}" class="user-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" name="is_admin"> Administrator privileges
|
||||
<span class="checkmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" name="auto_verify"> Auto-verify (skip email verification)
|
||||
<span class="checkmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-success">Create User</button>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
309
templates/dashboard.html
Normal file
309
templates/dashboard.html
Normal file
@@ -0,0 +1,309 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<h1>
|
||||
{% 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 %}
|
||||
</h1>
|
||||
|
||||
<!-- Admin-only sections -->
|
||||
{% if g.user.is_admin or g.user.role == Role.ADMIN %}
|
||||
<div class="stats-section">
|
||||
<h2>System Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>{{ total_users }}</h3>
|
||||
<p>Total Users</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{ total_teams }}</h3>
|
||||
<p>Total Teams</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{ blocked_users }}</h3>
|
||||
<p>Blocked Users</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{ unverified_users }}</h3>
|
||||
<p>Unverified Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel">
|
||||
<div class="admin-card">
|
||||
<h2>User Management</h2>
|
||||
<p>Manage user accounts, permissions, and roles.</p>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-primary">Manage Users</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>Team Management</h2>
|
||||
<p>Configure teams and their members.</p>
|
||||
<a href="{{ url_for('admin_teams') }}" class="btn btn-primary">Manage Teams</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>System Settings</h2>
|
||||
<p>Configure application-wide settings like registration and more.</p>
|
||||
<a href="{{ url_for('admin_settings') }}" class="btn btn-primary">System Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Team Leader and Supervisor sections -->
|
||||
{% if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] or g.user.is_admin %}
|
||||
<div class="team-section">
|
||||
<h2>Team Management</h2>
|
||||
|
||||
{% if teams %}
|
||||
<div class="team-stats">
|
||||
<div class="stat-card">
|
||||
<h3>{{ team_member_count }}</h3>
|
||||
<p>Team Members</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>{{ teams|length }}</h3>
|
||||
<p>Teams Managed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel">
|
||||
<div class="admin-card">
|
||||
<h2>Team Hours</h2>
|
||||
<p>View and monitor team member working hours.</p>
|
||||
<a href="{{ url_for('team_hours') }}" class="btn btn-primary">View Team Hours</a>
|
||||
</div>
|
||||
|
||||
{% if g.user.is_admin %}
|
||||
<div class="admin-card">
|
||||
<h2>Team Configuration</h2>
|
||||
<p>Create and manage team structures.</p>
|
||||
<a href="{{ url_for('admin_teams') }}" class="btn btn-primary">Configure Teams</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="team-members">
|
||||
<h3>Your Team Members</h3>
|
||||
{% if team_members %}
|
||||
<div class="members-grid">
|
||||
{% for member in team_members %}
|
||||
<div class="member-card">
|
||||
<h4>{{ member.username }}</h4>
|
||||
<p>{{ member.role.value if member.role else 'Team Member' }}</p>
|
||||
<p>{{ member.email }}</p>
|
||||
{% if member.is_blocked %}
|
||||
<span class="status blocked">Blocked</span>
|
||||
{% elif not member.is_verified %}
|
||||
<span class="status unverified">Unverified</span>
|
||||
{% else %}
|
||||
<span class="status active">Active</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No team members assigned yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="no-team">
|
||||
<p>You are not assigned to any team. Contact your administrator to be assigned to a team.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recent Activity section for all roles -->
|
||||
{% if recent_entries %}
|
||||
<div class="recent-activity">
|
||||
<h2>Recent Time Entries</h2>
|
||||
<div class="entries-table">
|
||||
<table class="time-history">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if g.user.is_admin or g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
||||
<th>User</th>
|
||||
{% endif %}
|
||||
<th>Date</th>
|
||||
<th>Arrival</th>
|
||||
<th>Departure</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr>
|
||||
{% if g.user.is_admin or g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
||||
<td>{{ entry.user.username }}</td>
|
||||
{% endif %}
|
||||
<td>{{ entry.arrival_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ entry.arrival_time.strftime('%H:%M:%S') }}</td>
|
||||
<td>{{ entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active' }}</td>
|
||||
<td>
|
||||
{% if entry.duration %}
|
||||
{{ '%d:%02d:%02d'|format(entry.duration//3600, (entry.duration%3600)//60, entry.duration%60) }}
|
||||
{% else %}
|
||||
In progress
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not entry.departure_time %}
|
||||
<span class="status active">Active</span>
|
||||
{% else %}
|
||||
<span class="status completed">Completed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions section -->
|
||||
<div class="quick-actions">
|
||||
<h2>Quick Actions</h2>
|
||||
<div class="admin-panel">
|
||||
<div class="admin-card">
|
||||
<h2>My Profile</h2>
|
||||
<p>Update your personal information and password.</p>
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-secondary">Edit Profile</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>Configuration</h2>
|
||||
<p>Configure work hours and break settings.</p>
|
||||
<a href="{{ url_for('config') }}" class="btn btn-secondary">Work Config</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2>Time History</h2>
|
||||
<p>View your complete time tracking history.</p>
|
||||
<a href="{{ url_for('history') }}" class="btn btn-secondary">View History</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.stat-card p {
|
||||
margin: 0;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.team-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.member-card h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.member-card p {
|
||||
margin: 0.25rem 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.blocked {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status.unverified {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status.completed {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.no-team {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.entries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.recent-activity, .team-section, .quick-actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
59
templates/edit_user.html
Normal file
59
templates/edit_user.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-container">
|
||||
<h1>Edit User: {{ user.username }}</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('edit_user', user_id=user.id) }}" class="user-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" value="{{ user.username }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" value="{{ user.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">New Password (leave blank to keep current)</label>
|
||||
<input type="password" id="password" name="password" class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">Role</label>
|
||||
<select id="role" name="role" class="form-control">
|
||||
{% for role in roles %}
|
||||
<option value="{{ role.name }}" {% if user.role == role %}selected{% endif %}>
|
||||
{{ role.name.replace('_', ' ').title() }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="team_id">Team</label>
|
||||
<select id="team_id" name="team_id" class="form-control">
|
||||
<option value="">-- No Team --</option>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}" {% if user.team_id == team.id %}selected{% endif %}>
|
||||
{{ team.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %}> Administrator privileges
|
||||
<span class="checkmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Update User</button>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -6,6 +6,12 @@
|
||||
<p>Track your work hours easily and efficiently</p>
|
||||
</div>
|
||||
|
||||
{% if not g.user %}
|
||||
|
||||
Please <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('register') }}">register</a> to access your dashboard.
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="timetrack-container">
|
||||
<h2>Time Tracking</h2>
|
||||
|
||||
@@ -81,6 +87,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>Easy Time Tracking</h3>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimeTrack - {{ title }}</title>
|
||||
<title>{{ title }} - TimeTrack</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
@@ -11,20 +11,70 @@
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('home') }}">Home</a></li>
|
||||
<li><a href="{{ url_for('history') }}">Complete History</a></li>
|
||||
<li><a href="{{ url_for('export') }}">Export Data</a></li>
|
||||
<li><a href="{{ url_for('config') }}" {% if title == 'Configuration' %}class="active"{% endif %}>Configuration</a></li>
|
||||
<li><a href="{{ url_for('about') }}">About</a></li>
|
||||
{% if g.user %}
|
||||
<li><a href="{{ url_for('history') }}">History</a></li>
|
||||
<li><a href="{{ url_for('about') }}">About</a></li>
|
||||
|
||||
<!-- Role-based dropdown menu -->
|
||||
{% if g.user.is_admin %}
|
||||
<li class="dropdown admin-dropdown">
|
||||
<a href="#" class="dropdown-toggle">Admin</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
||||
<li><a href="{{ url_for('config') }}">Config</a></li>
|
||||
<li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
|
||||
<li><a href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% elif g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] %}
|
||||
<!-- Team Leader/Supervisor dropdown menu -->
|
||||
<li class="dropdown admin-dropdown">
|
||||
<a href="#" class="dropdown-toggle">{{ g.user.username }}</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
||||
<li><a href="{{ url_for('config') }}">Config</a></li>
|
||||
<li><a href="{{ url_for('dashboard') }}">Dashboard</a></li>
|
||||
{% if g.user.team_id %}
|
||||
<li><a href="{{ url_for('team_hours') }}">Team Hours</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<!-- Regular user dropdown menu -->
|
||||
<li class="dropdown admin-dropdown">
|
||||
<a href="#" class="dropdown-toggle">{{ g.user.username }}</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{{ url_for('profile') }}">Profile</a></li>
|
||||
<li><a href="{{ url_for('config') }}">Config</a></li>
|
||||
<li><a href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('login') }}">Login</a></li>
|
||||
<li><a href="{{ url_for('register') }}">Register</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 TimeTrack</p>
|
||||
<p>© {{ current_year }} TimeTrack. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
|
||||
42
templates/login.html
Normal file
42
templates/login.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<h1>Login to TimeTrack</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('login') }}" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" name="remember"> Remember me
|
||||
<span class="checkmark"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
96
templates/manage_team.html
Normal file
96
templates/manage_team.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h1>Manage Team: {{ team.name }}</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2>Team Details</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('manage_team', team_id=team.id) }}">
|
||||
<input type="hidden" name="action" value="update_team">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Team Name</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ team.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{ team.description }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update Team</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h2>Team Members</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if team_members %}
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in team_members %}
|
||||
<tr>
|
||||
<td>{{ member.username }}</td>
|
||||
<td>{{ member.email }}</td>
|
||||
<td>{{ member.role.value }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('manage_team', team_id=team.id) }}" class="d-inline">
|
||||
<input type="hidden" name="action" value="remove_member">
|
||||
<input type="hidden" name="user_id" value="{{ member.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to remove this user from the team?')">
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No members in this team yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Add Team Member</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if available_users %}
|
||||
<form method="POST" action="{{ url_for('manage_team', team_id=team.id) }}">
|
||||
<input type="hidden" name="action" value="add_member">
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">Select User</label>
|
||||
<select class="form-select" id="user_id" name="user_id" required>
|
||||
<option value="">-- Select User --</option>
|
||||
{% for user in available_users %}
|
||||
<option value="{{ user.id }}">{{ user.username }} ({{ user.email }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Add to Team</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>No available users to add to this team.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('admin_teams') }}" class="btn btn-secondary">Back to Teams</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
239
templates/profile.html
Normal file
239
templates/profile.html
Normal file
@@ -0,0 +1,239 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-container">
|
||||
<h1>My Profile</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="profile-info">
|
||||
<p><strong>Username:</strong> {{ user.username }}</p>
|
||||
<p><strong>Account Type:</strong> {% if user.is_admin %}Administrator{% else %}User{% endif %}</p>
|
||||
<p><strong>Member Since:</strong> {{ user.created_at.strftime('%Y-%m-%d') }}</p>
|
||||
<p><strong>Two-Factor Authentication:</strong>
|
||||
{% if user.two_factor_enabled %}
|
||||
<span class="status enabled">✅ Enabled</span>
|
||||
{% else %}
|
||||
<span class="status disabled">❌ Disabled</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Profile Settings</h2>
|
||||
|
||||
<div class="profile-card">
|
||||
<h3>Basic Information</h3>
|
||||
<form method="POST" action="{{ url_for('profile') }}" class="profile-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" class="form-control" value="{{ user.email }}" required>
|
||||
<small>This email address is used for account verification and notifications.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Update Email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="profile-card">
|
||||
<h3>Change Password</h3>
|
||||
<p>Update your account password to keep your account secure.</p>
|
||||
<form method="POST" action="{{ url_for('profile') }}" class="password-form">
|
||||
<!-- Hidden email field to maintain current email -->
|
||||
<input type="hidden" name="email" value="{{ user.email }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="current_password">Current Password</label>
|
||||
<input type="password" id="current_password" name="current_password" class="form-control" required>
|
||||
<small>Enter your current password to verify your identity.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password" class="form-control" required>
|
||||
<small>Choose a strong password with at least 8 characters.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm New Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
||||
<small>Re-enter your new password to confirm.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-warning">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="security-section">
|
||||
<h2>Security Settings</h2>
|
||||
|
||||
<div class="security-card">
|
||||
<h3>Two-Factor Authentication</h3>
|
||||
{% if user.two_factor_enabled %}
|
||||
<p>Two-factor authentication is <strong>enabled</strong> for your account. This adds an extra layer of security by requiring a code from your authenticator app when logging in.</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('disable_2fa') }}" class="disable-2fa-form" onsubmit="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.');">
|
||||
<div class="form-group">
|
||||
<label for="password_disable">Enter your password to disable 2FA:</label>
|
||||
<input type="password" id="password_disable" name="password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">Disable Two-Factor Authentication</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Two-factor authentication is <strong>not enabled</strong> for your account. We strongly recommend enabling it to protect your account.</p>
|
||||
<p>With 2FA enabled, you'll need both your password and a code from your phone to log in.</p>
|
||||
|
||||
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">Enable Two-Factor Authentication</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status.enabled {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status.disabled {
|
||||
color: #dc3545;
|
||||
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;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.security-card {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.security-card h3 {
|
||||
color: #007bff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.disable-2fa-form {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
50
templates/register.html
Normal file
50
templates/register.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<h1>Register for TimeTrack</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('register') }}" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" required>
|
||||
<small class="form-text text-muted">A verification link will be sent to this email address.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Register</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-links">
|
||||
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
|
||||
</div>
|
||||
|
||||
<div class="verification-notice">
|
||||
<p>After registration, you'll need to verify your email address before you can log in.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
266
templates/setup_2fa.html
Normal file
266
templates/setup_2fa.html
Normal file
@@ -0,0 +1,266 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="setup-2fa-container">
|
||||
<h1>Setup Two-Factor Authentication</h1>
|
||||
|
||||
<div class="setup-steps">
|
||||
<div class="step">
|
||||
<h2>Step 1: Install an Authenticator App</h2>
|
||||
<p>Download and install an authenticator app on your mobile device:</p>
|
||||
<ul>
|
||||
<li><strong>Google Authenticator</strong> (iOS/Android)</li>
|
||||
<li><strong>Microsoft Authenticator</strong> (iOS/Android)</li>
|
||||
<li><strong>Authy</strong> (iOS/Android/Desktop)</li>
|
||||
<li><strong>1Password</strong> (Premium feature)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>Step 2: Scan QR Code or Enter Secret</h2>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code">
|
||||
</div>
|
||||
<div class="manual-entry">
|
||||
<h3>Can't scan? Enter this code manually:</h3>
|
||||
<div class="secret-code">{{ secret }}</div>
|
||||
<p><small>Account: {{ g.user.email }}<br>Issuer: TimeTrack</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<h2>Step 3: Verify Setup</h2>
|
||||
<p>Enter the 6-digit code from your authenticator app to complete setup:</p>
|
||||
|
||||
<form method="POST" class="verification-form">
|
||||
<div class="form-group">
|
||||
<label for="totp_code">Verification Code:</label>
|
||||
<input type="text" id="totp_code" name="totp_code"
|
||||
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
|
||||
required autocomplete="off" autofocus>
|
||||
<small>Enter the 6-digit code from your authenticator app</small>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn btn-primary">Enable Two-Factor Authentication</button>
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="security-notice">
|
||||
<h3>🔐 Security Notice</h3>
|
||||
<p><strong>Important:</strong> Once enabled, you'll need your authenticator app to log in. Make sure to:</p>
|
||||
<ul>
|
||||
<li>Keep your authenticator app secure and backed up</li>
|
||||
<li>Store the secret code in a safe place as a backup</li>
|
||||
<li>Remember your password - you'll need both your password and 2FA code to log in</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setup-2fa-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.setup-steps {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.step h2 {
|
||||
color: #007bff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.step ul {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.step li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-code img {
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.manual-entry {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.secret-code {
|
||||
background: #f1f3f4;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.1rem;
|
||||
word-break: break-all;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.verification-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 200px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
letter-spacing: 0.2em;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
color: #6c757d;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.security-notice h3 {
|
||||
color: #856404;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.security-notice ul {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.security-notice li {
|
||||
margin: 0.5rem 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.qr-section {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setup-2fa-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const input = document.getElementById('totp_code');
|
||||
|
||||
// Auto-format input to digits only
|
||||
input.addEventListener('input', function(e) {
|
||||
e.target.value = e.target.value.replace(/\D/g, '');
|
||||
});
|
||||
|
||||
// Auto-submit when 6 digits are entered
|
||||
input.addEventListener('input', function(e) {
|
||||
if (e.target.value.length === 6) {
|
||||
// Small delay to let user see the complete code
|
||||
setTimeout(function() {
|
||||
document.querySelector('.verification-form').submit();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
184
templates/team_hours.html
Normal file
184
templates/team_hours.html
Normal file
@@ -0,0 +1,184 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timetrack-container">
|
||||
<h2>Team Hours</h2>
|
||||
|
||||
<div class="date-filter">
|
||||
<form id="date-range-form" method="GET" action="{{ url_for('team_hours') }}">
|
||||
<div class="form-group">
|
||||
<label for="start-date">Start Date:</label>
|
||||
<input type="date" id="start-date" name="start_date" value="{{ start_date.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end-date">End Date:</label>
|
||||
<input type="date" id="end-date" name="end_date" value="{{ end_date.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="include-self">
|
||||
<input type="checkbox" id="include-self" name="include_self" {% if request.args.get('include_self') %}checked{% endif %}> Include my hours
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn">Apply Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="team-hours-container">
|
||||
<div id="loading">Loading team data...</div>
|
||||
<div id="team-info" style="display: none;">
|
||||
<h3>Team: <span id="team-name"></span></h3>
|
||||
<p id="team-description"></p>
|
||||
</div>
|
||||
|
||||
<div id="team-hours-table" style="display: none;">
|
||||
<table class="time-history">
|
||||
<thead id="table-header">
|
||||
<tr>
|
||||
<th>Team Member</th>
|
||||
{% for date in date_range %}
|
||||
<th>{{ date.strftime('%a, %b %d') }}</th>
|
||||
{% endfor %}
|
||||
<th>Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<!-- Team member data will be added dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div id="no-data" style="display: none;">
|
||||
<p>No time entries found for the selected date range.</p>
|
||||
</div>
|
||||
|
||||
<div id="error-message" style="display: none;" class="error-message">
|
||||
<!-- Error messages will be displayed here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="team-hours-details" id="member-details" style="display: none;">
|
||||
<h3>Detailed Entries for <span id="selected-member"></span></h3>
|
||||
<table class="time-history">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Arrival</th>
|
||||
<th>Departure</th>
|
||||
<th>Work Duration</th>
|
||||
<th>Break Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="details-body">
|
||||
<!-- Entry details will be added dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load team hours data when the page loads
|
||||
loadTeamHoursData();
|
||||
|
||||
// Handle date filter form submission
|
||||
document.getElementById('date-range-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
loadTeamHoursData();
|
||||
});
|
||||
|
||||
function loadTeamHoursData() {
|
||||
// Show loading indicator
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('team-hours-table').style.display = 'none';
|
||||
document.getElementById('team-info').style.display = 'none';
|
||||
document.getElementById('no-data').style.display = 'none';
|
||||
document.getElementById('error-message').style.display = 'none';
|
||||
document.getElementById('member-details').style.display = 'none';
|
||||
|
||||
// Get filter values
|
||||
const startDate = document.getElementById('start-date').value;
|
||||
const endDate = document.getElementById('end-date').value;
|
||||
const includeSelf = document.getElementById('include-self').checked;
|
||||
|
||||
// Build API URL with query parameters
|
||||
const apiUrl = `/api/team/hours_data?start_date=${startDate}&end_date=${endDate}&include_self=${includeSelf}`;
|
||||
|
||||
// Fetch data from API
|
||||
fetch(apiUrl)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.message || 'Failed to load team hours data');
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
displayTeamData(data);
|
||||
} else {
|
||||
showError(data.message || 'Failed to load team hours data.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching team hours data:', error);
|
||||
showError(error.message || 'An error occurred while loading the team hours data.');
|
||||
});
|
||||
}
|
||||
|
||||
function displayTeamData(data) {
|
||||
// Populate team info
|
||||
document.getElementById('team-name').textContent = data.team.name;
|
||||
document.getElementById('team-description').textContent = data.team.description || '';
|
||||
document.getElementById('team-info').style.display = 'block';
|
||||
|
||||
// Populate team hours table
|
||||
const tableHeader = document.getElementById('table-header').querySelector('tr');
|
||||
tableHeader.innerHTML = '<th>Team Member</th>';
|
||||
data.date_range.forEach(dateStr => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = new Date(dateStr).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
tableHeader.appendChild(th);
|
||||
});
|
||||
const totalHoursTh = document.createElement('th');
|
||||
totalHoursTh.textContent = 'Total Hours';
|
||||
tableHeader.appendChild(totalHoursTh);
|
||||
|
||||
const tableBody = document.getElementById('table-body');
|
||||
tableBody.innerHTML = '';
|
||||
data.team_data.forEach(memberData => {
|
||||
const row = document.createElement('tr');
|
||||
|
||||
// Add username cell
|
||||
const usernameCell = document.createElement('td');
|
||||
usernameCell.textContent = memberData.user.username;
|
||||
row.appendChild(usernameCell);
|
||||
|
||||
// Add daily hours cells
|
||||
data.date_range.forEach(dateStr => {
|
||||
const cell = document.createElement('td');
|
||||
cell.textContent = `${memberData.daily_hours[dateStr] || 0}h`;
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
// Add total hours cell
|
||||
const totalCell = document.createElement('td');
|
||||
totalCell.innerHTML = `<strong>${memberData.total_hours}h</strong>`;
|
||||
row.appendChild(totalCell);
|
||||
|
||||
tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
// Populate detailed entries
|
||||
document.getElementById('team-hours-table').style.display = 'block';
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
document.getElementById('error-message').textContent = message;
|
||||
document.getElementById('error-message').style.display = 'block';
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
182
templates/verify_2fa.html
Normal file
182
templates/verify_2fa.html
Normal file
@@ -0,0 +1,182 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="verify-2fa-container">
|
||||
<div class="verification-card">
|
||||
<h1>Two-Factor Authentication</h1>
|
||||
<p class="instruction">Please enter the 6-digit code from your authenticator app to complete login.</p>
|
||||
|
||||
<form method="POST" class="verification-form">
|
||||
<div class="form-group">
|
||||
<label for="totp_code">Verification Code:</label>
|
||||
<input type="text" id="totp_code" name="totp_code"
|
||||
placeholder="000000" maxlength="6" pattern="[0-9]{6}"
|
||||
required autocomplete="off" autofocus>
|
||||
<small>Enter the 6-digit code from your authenticator app</small>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn btn-primary">Verify & Login</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="help-section">
|
||||
<p><small>Having trouble? Make sure your device's time is synchronized and try a new code.</small></p>
|
||||
<p><small><a href="{{ url_for('login') }}">← Back to Login</a></small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.verify-2fa-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.verification-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.verification-card h1 {
|
||||
text-align: center;
|
||||
color: #007bff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.instruction {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.verification-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 200px;
|
||||
padding: 1rem;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
letter-spacing: 0.3em;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
color: #6c757d;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 2rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
text-align: center;
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.help-section p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.help-section a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.help-section a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.verify-2fa-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.verification-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const input = document.getElementById('totp_code');
|
||||
|
||||
// Auto-format input to digits only
|
||||
input.addEventListener('input', function(e) {
|
||||
e.target.value = e.target.value.replace(/\D/g, '');
|
||||
});
|
||||
|
||||
// Auto-submit when 6 digits are entered
|
||||
input.addEventListener('input', function(e) {
|
||||
if (e.target.value.length === 6) {
|
||||
// Small delay to let user see the complete code
|
||||
setTimeout(function() {
|
||||
document.querySelector('.verification-form').submit();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Focus on input when page loads
|
||||
input.focus();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user