mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-27 15:08:57 -06:00
BREAKING CHANGE: Permission system now actively enforced across all routes ## Summary Complete implementation of advanced role-based access control (RBAC) system with full route protection, UI conditionals, and enhanced management interface. ## Route Protection - Updated all admin routes to use @admin_or_permission_required decorator - Replaced inline admin checks with granular permission checks in: * Admin routes: user management, settings, backups, telemetry, OIDC * Project routes: create, edit, delete, archive, bulk operations * Client routes: create, edit, delete, archive, bulk operations - Maintained backward compatibility with existing @admin_required decorator ## UI Permission Integration - Added template helpers (has_permission, has_any_permission) to all templates - Navigation conditionally shows admin/OIDC links based on permissions - Action buttons (Edit, Delete, Archive) conditional on user permissions - Project and client pages respect permission requirements - Create buttons visible only with appropriate permissions ## Enhanced Roles & Permissions UI - Added statistics dashboard showing: * Total roles, system roles, custom roles, assigned users - Implemented expandable permission details in roles list * Click to view all permissions grouped by category * Visual checkmarks for assigned permissions - Enhanced user list with role visibility: * Shows all assigned roles as color-coded badges * Blue badges for system roles, gray for custom roles * Yellow badges for legacy roles with migration prompt * Merged legacy role column into unified "Roles & Permissions" - User count per role now clickable and accurate ## Security Improvements - Added CSRF tokens to all new permission system forms: * Role creation/edit form * Role deletion form * User role assignment form - All POST requests now protected against CSRF attacks ## Technical Details - Fixed SQLAlchemy relationship query issues (AppenderQuery) - Proper use of .count() for relationship aggregation - Jinja2 namespace for accumulating counts in templates - Responsive grid layouts for statistics and permission cards ## Documentation - Created comprehensive implementation guides - Added permission enforcement documentation - Documented UI enhancements and features - Included CSRF protection review ## Impact - Permissions are now actively enforced, not just defined - Admins can easily see who has what access - Clear visual indicators of permission assignments - Secure forms with CSRF protection - Production-ready permission system
225 lines
9.7 KiB
Python
225 lines
9.7 KiB
Python
from datetime import datetime
|
|
from flask_login import UserMixin
|
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
from app import db
|
|
import os
|
|
|
|
class User(UserMixin, db.Model):
|
|
"""User model for username-based authentication"""
|
|
|
|
__tablename__ = 'users'
|
|
__table_args__ = (
|
|
db.UniqueConstraint('oidc_issuer', 'oidc_sub', name='uq_users_oidc_issuer_sub'),
|
|
)
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
|
email = db.Column(db.String(200), nullable=True, index=True)
|
|
full_name = db.Column(db.String(200), nullable=True)
|
|
role = db.Column(db.String(20), default='user', nullable=False) # 'user' or 'admin'
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
|
last_login = db.Column(db.DateTime, nullable=True)
|
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
|
theme_preference = db.Column(db.String(10), default=None, nullable=True) # 'light' | 'dark' | None=system
|
|
preferred_language = db.Column(db.String(8), default=None, nullable=True) # e.g., 'en', 'de'
|
|
oidc_sub = db.Column(db.String(255), nullable=True)
|
|
oidc_issuer = db.Column(db.String(255), nullable=True)
|
|
avatar_filename = db.Column(db.String(255), nullable=True)
|
|
|
|
# User preferences and settings
|
|
email_notifications = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable email notifications
|
|
notification_overdue_invoices = db.Column(db.Boolean, default=True, nullable=False) # Notify about overdue invoices
|
|
notification_task_assigned = db.Column(db.Boolean, default=True, nullable=False) # Notify when assigned to task
|
|
notification_task_comments = db.Column(db.Boolean, default=True, nullable=False) # Notify about task comments
|
|
notification_weekly_summary = db.Column(db.Boolean, default=False, nullable=False) # Send weekly time summary
|
|
timezone = db.Column(db.String(50), nullable=True) # User-specific timezone override
|
|
date_format = db.Column(db.String(20), default='YYYY-MM-DD', nullable=False) # Date format preference
|
|
time_format = db.Column(db.String(10), default='24h', nullable=False) # '12h' or '24h'
|
|
week_start_day = db.Column(db.Integer, default=1, nullable=False) # 0=Sunday, 1=Monday, etc.
|
|
|
|
# Time rounding preferences
|
|
time_rounding_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable time rounding
|
|
time_rounding_minutes = db.Column(db.Integer, default=1, nullable=False) # Rounding interval: 1, 5, 10, 15, 30, 60
|
|
time_rounding_method = db.Column(db.String(10), default='nearest', nullable=False) # 'nearest', 'up', or 'down'
|
|
|
|
# Relationships
|
|
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
|
project_costs = db.relationship('ProjectCost', backref='user', lazy='dynamic', cascade='all, delete-orphan')
|
|
favorite_projects = db.relationship('Project', secondary='user_favorite_projects', lazy='dynamic', backref=db.backref('favorited_by', lazy='dynamic'))
|
|
roles = db.relationship('Role', secondary='user_roles', lazy='joined', backref=db.backref('users', lazy='dynamic'))
|
|
|
|
def __init__(self, username, role='user', email=None, full_name=None):
|
|
self.username = username.lower().strip()
|
|
self.role = role
|
|
self.email = (email or None)
|
|
self.full_name = (full_name or None)
|
|
|
|
def __repr__(self):
|
|
return f'<User {self.username}>'
|
|
|
|
@property
|
|
def is_admin(self):
|
|
"""Check if user is an admin"""
|
|
# Backward compatibility: check legacy role field first
|
|
if self.role == 'admin':
|
|
return True
|
|
# Check if user has any admin role
|
|
return any(role.name in ['admin', 'super_admin'] for role in self.roles)
|
|
|
|
@property
|
|
def active_timer(self):
|
|
"""Get the user's currently active timer"""
|
|
from .time_entry import TimeEntry
|
|
return TimeEntry.query.filter_by(
|
|
user_id=self.id,
|
|
end_time=None
|
|
).first()
|
|
|
|
@property
|
|
def total_hours(self):
|
|
"""Calculate total hours worked by this user"""
|
|
from .time_entry import TimeEntry
|
|
total_seconds = db.session.query(
|
|
db.func.sum(TimeEntry.duration_seconds)
|
|
).filter(
|
|
TimeEntry.user_id == self.id,
|
|
TimeEntry.end_time.isnot(None)
|
|
).scalar() or 0
|
|
return round(total_seconds / 3600, 2)
|
|
|
|
@property
|
|
def display_name(self):
|
|
"""Preferred display name: full name if available, else username"""
|
|
if self.full_name and self.full_name.strip():
|
|
return self.full_name.strip()
|
|
return self.username
|
|
|
|
def get_recent_entries(self, limit=10):
|
|
"""Get recent time entries for this user"""
|
|
from .time_entry import TimeEntry
|
|
return self.time_entries.filter(
|
|
TimeEntry.end_time.isnot(None)
|
|
).order_by(
|
|
TimeEntry.start_time.desc()
|
|
).limit(limit).all()
|
|
|
|
def update_last_login(self):
|
|
"""Update the last login timestamp"""
|
|
self.last_login = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
def to_dict(self):
|
|
"""Convert user to dictionary for API responses"""
|
|
return {
|
|
'id': self.id,
|
|
'username': self.username,
|
|
'email': self.email,
|
|
'full_name': self.full_name,
|
|
'display_name': self.display_name,
|
|
'role': self.role,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'last_login': self.last_login.isoformat() if self.last_login else None,
|
|
'is_active': self.is_active,
|
|
'total_hours': self.total_hours,
|
|
'avatar_url': self.get_avatar_url(),
|
|
}
|
|
|
|
# Avatar helpers
|
|
def get_avatar_url(self):
|
|
"""Return the public URL for the user's avatar, or None if not set"""
|
|
if self.avatar_filename:
|
|
return f"/uploads/avatars/{self.avatar_filename}"
|
|
return None
|
|
|
|
def get_avatar_path(self):
|
|
"""Return absolute filesystem path to the user's avatar, or None if not set"""
|
|
if not self.avatar_filename:
|
|
return None
|
|
try:
|
|
from flask import current_app
|
|
# Avatars are now stored in /data volume to persist between container updates
|
|
upload_folder = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'avatars')
|
|
return os.path.join(upload_folder, self.avatar_filename)
|
|
except Exception:
|
|
# Fallback for development/non-docker environments
|
|
return os.path.join('/data/uploads', 'avatars', self.avatar_filename)
|
|
|
|
def has_avatar(self):
|
|
"""Check whether the user's avatar file exists on disk"""
|
|
path = self.get_avatar_path()
|
|
return bool(path and os.path.exists(path))
|
|
|
|
# Favorite projects helpers
|
|
def add_favorite_project(self, project):
|
|
"""Add a project to user's favorites"""
|
|
if not self.is_project_favorite(project):
|
|
self.favorite_projects.append(project)
|
|
db.session.commit()
|
|
|
|
def remove_favorite_project(self, project):
|
|
"""Remove a project from user's favorites"""
|
|
if self.is_project_favorite(project):
|
|
self.favorite_projects.remove(project)
|
|
db.session.commit()
|
|
|
|
def is_project_favorite(self, project):
|
|
"""Check if a project is in user's favorites"""
|
|
from .project import Project
|
|
if isinstance(project, int):
|
|
project_id = project
|
|
return self.favorite_projects.filter_by(id=project_id).count() > 0
|
|
elif isinstance(project, Project):
|
|
return self.favorite_projects.filter_by(id=project.id).count() > 0
|
|
return False
|
|
|
|
def get_favorite_projects(self, status='active'):
|
|
"""Get user's favorite projects, optionally filtered by status"""
|
|
query = self.favorite_projects
|
|
if status:
|
|
query = query.filter_by(status=status)
|
|
return query.order_by('name').all()
|
|
|
|
# Permission and role helpers
|
|
def has_permission(self, permission_name):
|
|
"""Check if user has a specific permission through any of their roles"""
|
|
# Super admin users have all permissions
|
|
if self.role == 'admin' and not self.roles:
|
|
# Legacy admin users without roles have all permissions
|
|
return True
|
|
|
|
# Check if any of the user's roles have this permission
|
|
for role in self.roles:
|
|
if role.has_permission(permission_name):
|
|
return True
|
|
return False
|
|
|
|
def has_any_permission(self, *permission_names):
|
|
"""Check if user has any of the specified permissions"""
|
|
return any(self.has_permission(perm) for perm in permission_names)
|
|
|
|
def has_all_permissions(self, *permission_names):
|
|
"""Check if user has all of the specified permissions"""
|
|
return all(self.has_permission(perm) for perm in permission_names)
|
|
|
|
def add_role(self, role):
|
|
"""Add a role to this user"""
|
|
if role not in self.roles:
|
|
self.roles.append(role)
|
|
|
|
def remove_role(self, role):
|
|
"""Remove a role from this user"""
|
|
if role in self.roles:
|
|
self.roles.remove(role)
|
|
|
|
def get_all_permissions(self):
|
|
"""Get all permissions this user has through their roles"""
|
|
permissions = set()
|
|
for role in self.roles:
|
|
for permission in role.permissions:
|
|
permissions.add(permission)
|
|
return list(permissions)
|
|
|
|
def get_role_names(self):
|
|
"""Get list of role names for this user"""
|
|
return [r.name for r in self.roles]
|