Prune unverified accounts
This commit is contained in:
@@ -39,8 +39,16 @@ docker-compose up
|
||||
|
||||
# Debug mode with hot-reload
|
||||
docker-compose -f docker-compose.debug.yml up
|
||||
|
||||
# Manual cleanup of unverified accounts
|
||||
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py
|
||||
|
||||
# Dry run to see what would be deleted
|
||||
docker exec timetrack-timetrack-1 python cleanup_unverified_accounts.py --dry-run
|
||||
```
|
||||
|
||||
**Note:** Unverified accounts are automatically cleaned up every hour via cron job in the Docker container.
|
||||
|
||||
## Database Operations
|
||||
|
||||
### Flask-Migrate Commands
|
||||
@@ -187,6 +195,7 @@ When modifying billing/invoice features:
|
||||
- Two-factor authentication (2FA) using TOTP
|
||||
- Session-based authentication with "Remember Me" option
|
||||
- Email verification for new accounts (configurable)
|
||||
- Automatic cleanup of unverified accounts after 24 hours
|
||||
|
||||
### Mobile UI Features
|
||||
- Progressive Web App (PWA) manifest for installability
|
||||
|
||||
@@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
python3-dev \
|
||||
postgresql-client \
|
||||
cron \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -35,6 +36,12 @@ RUN pip install gunicorn==21.2.0
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Setup cron job for cleanup
|
||||
COPY docker-cron /etc/cron.d/cleanup-cron
|
||||
RUN chmod 0644 /etc/cron.d/cleanup-cron && \
|
||||
crontab /etc/cron.d/cleanup-cron && \
|
||||
touch /var/log/cron.log
|
||||
|
||||
# Create the SQLite database directory with proper permissions
|
||||
RUN mkdir -p /app/instance && chmod 777 /app/instance
|
||||
|
||||
|
||||
58
app.py
58
app.py
@@ -102,14 +102,14 @@ def force_http_scheme():
|
||||
if app.debug or os.environ.get('FLASK_ENV') == 'debug':
|
||||
from flask import url_for as original_url_for
|
||||
import functools
|
||||
|
||||
|
||||
@functools.wraps(original_url_for)
|
||||
def url_for_http(*args, **kwargs):
|
||||
# Force _scheme to http if _external is True
|
||||
if kwargs.get('_external'):
|
||||
kwargs['_scheme'] = 'http'
|
||||
return original_url_for(*args, **kwargs)
|
||||
|
||||
|
||||
app.jinja_env.globals['url_for'] = url_for_http
|
||||
|
||||
# Configure Flask-Mail
|
||||
@@ -365,7 +365,7 @@ def robots_txt():
|
||||
def sitemap_xml():
|
||||
"""Generate XML sitemap for search engines"""
|
||||
pages = []
|
||||
|
||||
|
||||
# Static pages accessible without login
|
||||
static_pages = [
|
||||
{'loc': '/', 'priority': '1.0', 'changefreq': 'daily'},
|
||||
@@ -373,7 +373,7 @@ def sitemap_xml():
|
||||
{'loc': '/register', 'priority': '0.9', 'changefreq': 'monthly'},
|
||||
{'loc': '/forgot_password', 'priority': '0.5', 'changefreq': 'monthly'},
|
||||
]
|
||||
|
||||
|
||||
for page in static_pages:
|
||||
pages.append({
|
||||
'loc': request.host_url[:-1] + page['loc'],
|
||||
@@ -381,10 +381,10 @@ def sitemap_xml():
|
||||
'priority': page['priority'],
|
||||
'changefreq': page['changefreq']
|
||||
})
|
||||
|
||||
|
||||
sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
sitemap_xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
|
||||
|
||||
for page in pages:
|
||||
sitemap_xml += ' <url>\n'
|
||||
sitemap_xml += f' <loc>{page["loc"]}</loc>\n'
|
||||
@@ -392,9 +392,9 @@ def sitemap_xml():
|
||||
sitemap_xml += f' <changefreq>{page["changefreq"]}</changefreq>\n'
|
||||
sitemap_xml += f' <priority>{page["priority"]}</priority>\n'
|
||||
sitemap_xml += ' </url>\n'
|
||||
|
||||
|
||||
sitemap_xml += '</urlset>'
|
||||
|
||||
|
||||
return Response(sitemap_xml, mimetype='application/xml')
|
||||
|
||||
@app.route('/site.webmanifest')
|
||||
@@ -986,11 +986,11 @@ def forgot_password():
|
||||
"""Handle forgot password requests"""
|
||||
if request.method == 'POST':
|
||||
username_or_email = request.form.get('username_or_email', '').strip()
|
||||
|
||||
|
||||
if not username_or_email:
|
||||
flash('Please enter your username or email address.', 'error')
|
||||
return render_template('forgot_password.html', title='Forgot Password')
|
||||
|
||||
|
||||
# Try to find user by username or email
|
||||
user = User.query.filter(
|
||||
db.or_(
|
||||
@@ -998,11 +998,11 @@ def forgot_password():
|
||||
User.email == username_or_email
|
||||
)
|
||||
).first()
|
||||
|
||||
|
||||
if user and user.email:
|
||||
# Generate reset token
|
||||
token = user.generate_password_reset_token()
|
||||
|
||||
|
||||
# Send reset email
|
||||
reset_url = url_for('reset_password', token=token, _external=True)
|
||||
msg = Message(
|
||||
@@ -1023,7 +1023,7 @@ If you did not request a password reset, please ignore this email.
|
||||
Best regards,
|
||||
The {g.branding.app_name if g.branding else "TimeTrack"} Team
|
||||
'''
|
||||
|
||||
|
||||
try:
|
||||
mail.send(msg)
|
||||
logger.info(f"Password reset email sent to user {user.username}")
|
||||
@@ -1031,11 +1031,11 @@ The {g.branding.app_name if g.branding else "TimeTrack"} Team
|
||||
logger.error(f"Failed to send password reset email: {str(e)}")
|
||||
flash('Failed to send reset email. Please contact support.', 'error')
|
||||
return render_template('forgot_password.html', title='Forgot Password')
|
||||
|
||||
|
||||
# Always show success message to prevent user enumeration
|
||||
flash('If an account exists with that username or email address, we have sent a password reset link.', 'success')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
return render_template('forgot_password.html', title='Forgot Password')
|
||||
|
||||
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
|
||||
@@ -1043,42 +1043,42 @@ def reset_password(token):
|
||||
"""Handle password reset with token"""
|
||||
# Find user by reset token
|
||||
user = User.query.filter_by(password_reset_token=token).first()
|
||||
|
||||
|
||||
if not user or not user.verify_password_reset_token(token):
|
||||
flash('Invalid or expired reset link.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
password = request.form.get('password')
|
||||
confirm_password = request.form.get('confirm_password')
|
||||
|
||||
|
||||
# Validate input
|
||||
error = None
|
||||
if not password:
|
||||
error = 'Password is required'
|
||||
elif password != confirm_password:
|
||||
error = 'Passwords do not match'
|
||||
|
||||
|
||||
# Validate password strength
|
||||
if not error:
|
||||
validator = PasswordValidator()
|
||||
is_valid, password_errors = validator.validate(password)
|
||||
if not is_valid:
|
||||
error = password_errors[0]
|
||||
|
||||
|
||||
if error:
|
||||
flash(error, 'error')
|
||||
return render_template('reset_password.html', token=token, title='Reset Password')
|
||||
|
||||
|
||||
# Update password
|
||||
user.set_password(password)
|
||||
user.clear_password_reset_token()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
logger.info(f"Password reset successful for user {user.username}")
|
||||
flash('Your password has been reset successfully. Please log in with your new password.', 'success')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
return render_template('reset_password.html', token=token, title='Reset Password')
|
||||
|
||||
@app.route('/dashboard')
|
||||
@@ -2931,14 +2931,14 @@ def render_markdown():
|
||||
try:
|
||||
data = request.get_json()
|
||||
content = data.get('content', '')
|
||||
|
||||
|
||||
if not content:
|
||||
return jsonify({'html': '<p class="preview-placeholder">Start typing to see the preview...</p>'})
|
||||
|
||||
|
||||
# Parse frontmatter and extract body
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
metadata, body = parse_frontmatter(content)
|
||||
|
||||
|
||||
# Render markdown to HTML
|
||||
try:
|
||||
import markdown
|
||||
@@ -2947,13 +2947,13 @@ def render_markdown():
|
||||
except ImportError:
|
||||
# Fallback if markdown not installed
|
||||
html = f'<pre>{body}</pre>'
|
||||
|
||||
|
||||
return jsonify({'html': html})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering markdown: {str(e)}")
|
||||
return jsonify({'html': '<p class="error">Error rendering markdown</p>'})
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', 5000))
|
||||
app.run(debug=True, host='0.0.0.0', port=port)
|
||||
app.run(debug=True, host='0.0.0.0', port=port)
|
||||
|
||||
@@ -73,6 +73,16 @@ if [ -f "migrations_old/run_postgres_migrations.py" ]; then
|
||||
echo "Found old migration system. Consider removing after confirming Flask-Migrate is working."
|
||||
fi
|
||||
|
||||
# Start cron service for scheduled tasks
|
||||
echo ""
|
||||
echo "=== Starting Cron Service ==="
|
||||
service cron start
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Cron service started for scheduled cleanup tasks"
|
||||
else
|
||||
echo "⚠️ Failed to start cron service, cleanup tasks won't run automatically"
|
||||
fi
|
||||
|
||||
# Start the Flask application with gunicorn
|
||||
echo ""
|
||||
echo "=== Starting Application ==="
|
||||
|
||||
@@ -8,72 +8,72 @@ from models import db
|
||||
|
||||
class BaseRepository:
|
||||
"""Base repository with common database operations"""
|
||||
|
||||
|
||||
def __init__(self, model):
|
||||
self.model = model
|
||||
|
||||
|
||||
def get_by_id(self, id):
|
||||
"""Get entity by ID"""
|
||||
return self.model.query.get(id)
|
||||
|
||||
|
||||
def get_by_company(self, company_id=None):
|
||||
"""Get all entities for a company"""
|
||||
if company_id is None and hasattr(g, 'user') and g.user:
|
||||
company_id = g.user.company_id
|
||||
|
||||
|
||||
if company_id is None:
|
||||
return []
|
||||
|
||||
|
||||
return self.model.query.filter_by(company_id=company_id).all()
|
||||
|
||||
|
||||
def get_by_company_ordered(self, company_id=None, order_by=None):
|
||||
"""Get all entities for a company with ordering"""
|
||||
if company_id is None and hasattr(g, 'user') and g.user:
|
||||
company_id = g.user.company_id
|
||||
|
||||
|
||||
if company_id is None:
|
||||
return []
|
||||
|
||||
|
||||
query = self.model.query.filter_by(company_id=company_id)
|
||||
|
||||
|
||||
if order_by is not None:
|
||||
query = query.order_by(order_by)
|
||||
|
||||
|
||||
return query.all()
|
||||
|
||||
|
||||
def exists_by_name_in_company(self, name, company_id=None, exclude_id=None):
|
||||
"""Check if entity with name exists in company"""
|
||||
if company_id is None and hasattr(g, 'user') and g.user:
|
||||
company_id = g.user.company_id
|
||||
|
||||
|
||||
query = self.model.query.filter_by(name=name, company_id=company_id)
|
||||
|
||||
|
||||
if exclude_id is not None:
|
||||
query = query.filter(self.model.id != exclude_id)
|
||||
|
||||
|
||||
return query.first() is not None
|
||||
|
||||
|
||||
def create(self, **kwargs):
|
||||
"""Create new entity"""
|
||||
entity = self.model(**kwargs)
|
||||
db.session.add(entity)
|
||||
return entity
|
||||
|
||||
|
||||
def update(self, entity, **kwargs):
|
||||
"""Update entity with given attributes"""
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(entity, key):
|
||||
setattr(entity, key, value)
|
||||
return entity
|
||||
|
||||
|
||||
def delete(self, entity):
|
||||
"""Delete entity"""
|
||||
db.session.delete(entity)
|
||||
|
||||
|
||||
def save(self):
|
||||
"""Commit changes to database"""
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def rollback(self):
|
||||
"""Rollback database changes"""
|
||||
db.session.rollback()
|
||||
@@ -81,42 +81,42 @@ class BaseRepository:
|
||||
|
||||
class CompanyScopedRepository(BaseRepository):
|
||||
"""Repository for entities scoped to a company"""
|
||||
|
||||
|
||||
def get_by_id_and_company(self, id, company_id=None):
|
||||
"""Get entity by ID, ensuring it belongs to the company"""
|
||||
if company_id is None and hasattr(g, 'user') and g.user:
|
||||
company_id = g.user.company_id
|
||||
|
||||
|
||||
if company_id is None:
|
||||
return None
|
||||
|
||||
|
||||
return self.model.query.filter_by(id=id, company_id=company_id).first()
|
||||
|
||||
|
||||
def get_active_by_company(self, company_id=None):
|
||||
"""Get active entities for a company"""
|
||||
if company_id is None and hasattr(g, 'user') and g.user:
|
||||
company_id = g.user.company_id
|
||||
|
||||
|
||||
if company_id is None:
|
||||
return []
|
||||
|
||||
|
||||
# Assumes model has is_active field
|
||||
if hasattr(self.model, 'is_active'):
|
||||
return self.model.query.filter_by(
|
||||
company_id=company_id,
|
||||
company_id=company_id,
|
||||
is_active=True
|
||||
).all()
|
||||
|
||||
|
||||
return self.get_by_company(company_id)
|
||||
|
||||
|
||||
def count_by_company(self, company_id=None):
|
||||
"""Count entities for a company"""
|
||||
if company_id is None and hasattr(g, 'user') and g.user:
|
||||
company_id = g.user.company_id
|
||||
|
||||
|
||||
if company_id is None:
|
||||
return 0
|
||||
|
||||
|
||||
return self.model.query.filter_by(company_id=company_id).count()
|
||||
|
||||
|
||||
@@ -124,18 +124,18 @@ class CompanyScopedRepository(BaseRepository):
|
||||
|
||||
class UserRepository(CompanyScopedRepository):
|
||||
"""Repository for User operations"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
from models import User
|
||||
super().__init__(User)
|
||||
|
||||
|
||||
def get_by_username_and_company(self, username, company_id):
|
||||
"""Get user by username within a company"""
|
||||
return self.model.query.filter_by(
|
||||
username=username,
|
||||
username=username,
|
||||
company_id=company_id
|
||||
).first()
|
||||
|
||||
|
||||
def get_by_email(self, email):
|
||||
"""Get user by email (globally unique)"""
|
||||
return self.model.query.filter_by(email=email).first()
|
||||
@@ -143,19 +143,19 @@ class UserRepository(CompanyScopedRepository):
|
||||
|
||||
class TeamRepository(CompanyScopedRepository):
|
||||
"""Repository for Team operations"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
from models import Team
|
||||
super().__init__(Team)
|
||||
|
||||
|
||||
def get_with_member_count(self, company_id=None):
|
||||
"""Get teams with member count"""
|
||||
if company_id is None and hasattr(g, 'user') and g.user:
|
||||
company_id = g.user.company_id
|
||||
|
||||
|
||||
if company_id is None:
|
||||
return []
|
||||
|
||||
|
||||
# This would need a more complex query with joins
|
||||
teams = self.get_by_company(company_id)
|
||||
for team in teams:
|
||||
@@ -165,27 +165,27 @@ class TeamRepository(CompanyScopedRepository):
|
||||
|
||||
class ProjectRepository(CompanyScopedRepository):
|
||||
"""Repository for Project operations"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
from models import Project
|
||||
super().__init__(Project)
|
||||
|
||||
|
||||
def get_by_code_and_company(self, code, company_id):
|
||||
"""Get project by code within a company"""
|
||||
return self.model.query.filter_by(
|
||||
code=code,
|
||||
code=code,
|
||||
company_id=company_id
|
||||
).first()
|
||||
|
||||
|
||||
def get_accessible_by_user(self, user):
|
||||
"""Get projects accessible by a user"""
|
||||
if not user:
|
||||
return []
|
||||
|
||||
|
||||
# Admin/Supervisor can see all company projects
|
||||
if user.role.value in ['Administrator', 'Supervisor', 'System Administrator']:
|
||||
return self.get_by_company(user.company_id)
|
||||
|
||||
|
||||
# Team members see team projects + unassigned projects
|
||||
from models import Project
|
||||
return Project.query.filter(
|
||||
@@ -194,4 +194,4 @@ class ProjectRepository(CompanyScopedRepository):
|
||||
Project.team_id == user.team_id,
|
||||
Project.team_id.is_(None)
|
||||
)
|
||||
).all()
|
||||
).all()
|
||||
|
||||
Reference in New Issue
Block a user