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