diff --git a/app/models/user.py b/app/models/user.py index 52de99b..2151216 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -148,6 +148,12 @@ class User(UserMixin, db.Model): # Check if user has any admin role return any(role.name in ["admin", "super_admin"] for role in self.roles) + @property + def is_super_admin(self): + """Check if user is a super admin""" + # Check if user has super_admin role + return any(role.name == "super_admin" for role in self.roles) + @property def active_timer(self): """Get the user's currently active timer""" diff --git a/app/routes/gantt.py b/app/routes/gantt.py index 13d2e96..825f78a 100644 --- a/app/routes/gantt.py +++ b/app/routes/gantt.py @@ -47,7 +47,11 @@ def gantt_data(): if project_id: query = query.filter_by(id=project_id) - if not current_user.is_admin: + # Check if user has permission to view all projects + # Users with view_projects permission can see all projects, otherwise filter by their own + has_view_all_projects = current_user.is_admin or current_user.has_permission("view_projects") + + if not has_view_all_projects: # Filter by user's projects or projects they have time entries for query = query.filter( db.or_( diff --git a/app/routes/permissions.py b/app/routes/permissions.py index 9c31e42..372e49e 100644 --- a/app/routes/permissions.py +++ b/app/routes/permissions.py @@ -218,18 +218,43 @@ def manage_user_roles(user_id): # Get selected role IDs role_ids = request.form.getlist("roles") + # Validate role assignments - only super_admins can assign super_admin roles + # and only super_admins can remove admin roles + is_super_admin = current_user.is_super_admin + selected_roles = [Role.query.get(int(role_id)) for role_id in role_ids if role_id] + selected_roles = [r for r in selected_roles if r] # Remove None values + + # Check if trying to assign super_admin role + has_super_admin = any(r.name == "super_admin" for r in selected_roles) + if has_super_admin and not is_super_admin: + flash(_("Only Super Admins can assign the super_admin role"), "error") + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/users/roles.html", user=user, all_roles=all_roles) + + # Check if trying to remove admin role from self + current_has_admin = any(r.name == "admin" for r in user.roles) + new_has_admin = any(r.name == "admin" for r in selected_roles) + if current_has_admin and not new_has_admin and user.id == current_user.id and not is_super_admin: + flash(_("Only Super Admins can remove the admin role from themselves"), "error") + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/users/roles.html", user=user, all_roles=all_roles) + + # Check if trying to remove admin role from another user + if current_has_admin and not new_has_admin and user.id != current_user.id and not is_super_admin: + flash(_("Only Super Admins can remove the admin role from other users"), "error") + all_roles = Role.query.order_by(Role.name).all() + return render_template("admin/users/roles.html", user=user, all_roles=all_roles) + # Clear current roles user.roles = [] # Assign selected roles primary_role_name = None - for role_id in role_ids: - role = Role.query.get(int(role_id)) - if role: - user.add_role(role) - # Use the first role as the primary role for backward compatibility - if primary_role_name is None: - primary_role_name = role.name + for role in selected_roles: + user.add_role(role) + # Use the first role as the primary role for backward compatibility + if primary_role_name is None: + primary_role_name = role.name # Update legacy role field for backward compatibility # This ensures the old role field stays in sync with the new role system diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 4b72ea7..02743e0 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -43,6 +43,7 @@ def list_tasks(): overdue=overdue, user_id=current_user.id, is_admin=current_user.is_admin, + has_view_all_tasks=current_user.is_admin or current_user.has_permission("view_all_tasks"), page=page, per_page=per_page, ) @@ -1033,8 +1034,9 @@ def export_tasks(): today_local = now_in_app_timezone().date() query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"])) - # Show user's tasks first, then others - if not current_user.is_admin: + # Permission filter - users without view_all_tasks permission only see their tasks + has_view_all_tasks = current_user.is_admin or current_user.has_permission("view_all_tasks") + if not has_view_all_tasks: query = query.filter(db.or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id)) tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() diff --git a/app/services/task_service.py b/app/services/task_service.py index 24fb7eb..cb66a9a 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -179,6 +179,7 @@ class TaskService: overdue: bool = False, user_id: Optional[int] = None, is_admin: bool = False, + has_view_all_tasks: bool = False, page: int = 1, per_page: int = 20, ) -> Dict[str, Any]: @@ -229,8 +230,8 @@ class TaskService: today_local = now_in_app_timezone().date() query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"])) - # Permission filter - non-admins only see their tasks - if not is_admin and user_id: + # Permission filter - users without view_all_tasks permission only see their tasks + if not has_view_all_tasks and user_id: query = query.filter(db.or_(Task.assigned_to == user_id, Task.created_by == user_id)) logger.debug(f"[TaskService.list_tasks] Step 3: Applying filters took {(time.time() - step_start) * 1000:.2f}ms") diff --git a/docker-compose.example.yml b/docker-compose.example.yml index f7897c2..617e82d 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -12,6 +12,8 @@ services: - ADMIN_USERNAMES=${ADMIN_USERNAMES:-admin} # Security (required in production) - SECRET_KEY=${SECRET_KEY} + # Version (inherited from image, but can be overridden) + - APP_VERSION=${APP_VERSION:-} # Database (bundled Postgres) - DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker # CSRF & cookies (safe for HTTP local; tighten for HTTPS)