diff --git a/Makefile b/Makefile index ce392da..e7f6bf0 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ DC_DB := docker compose -f $(COMPOSE_DB) DC_ALL := docker compose -f $(COMPOSE_DB) -f $(COMPOSE_BACKEND) .PHONY: db-up db-down db-inspect db-logs db-reset -.PHONY: backend-build backend-up backend-down backend-logs +.PHONY: backend-build backend-up backend-down backend-logs backend-rebuild .PHONY: up down reset # Database @@ -38,6 +38,11 @@ backend-down: backend-logs: $(DC_ALL) logs -f backend +backend-rebuild: + $(DC_ALL) down + $(DC_ALL) build backend + $(DC_ALL) up -d --wait + # Combined up: $(DC_ALL) up -d --wait diff --git a/backend/migrations/versions/93a1f8b3c4d5_add_audit_logs_and_system_settings.py b/backend/migrations/versions/93a1f8b3c4d5_add_audit_logs_and_system_settings.py new file mode 100644 index 0000000..0bd93fa --- /dev/null +++ b/backend/migrations/versions/93a1f8b3c4d5_add_audit_logs_and_system_settings.py @@ -0,0 +1,105 @@ +"""Add audit logs and system settings + +Revision ID: 93a1f8b3c4d5 +Revises: e2f4g5h6i7j8 +Create Date: 2026-05-07 00:39:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import json +from datetime import datetime, timezone + +# revision identifiers, used by Alembic. +revision = '93a1f8b3c4d5' +down_revision = 'e2f4g5h6i7j8' +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('actor_user_id', sa.Integer(), nullable=True), + sa.Column('actor_role', sa.String(length=20), nullable=True), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('resource_type', sa.String(length=50), nullable=True), + sa.Column('resource_id', sa.String(length=50), nullable=True), + sa.Column('ip', sa.String(length=45), nullable=True), + sa.Column('user_agent', sa.String(length=255), nullable=True), + sa.Column('metadata_info', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_audit_logs_actor_time', 'audit_logs', ['actor_user_id', 'created_at'], unique=False) + op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False) + op.create_index('idx_audit_logs_resource', 'audit_logs', ['resource_type'], unique=False) + + system_settings_table = op.create_table('system_settings', + sa.Column('key', sa.String(length=100), nullable=False), + sa.Column('value', sa.JSON(), nullable=False), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('key') + ) + + bind = op.get_bind() + if bind.dialect.name == 'postgresql': + op.create_check_constraint( + 'check_valid_role', + 'users', + "role IN ('developer', 'team_lead', 'admin')" + ) + + # Seed default system settings + op.bulk_insert( + system_settings_table, + [ + { + 'key': 'app_name', + 'value': 'DevSync', + 'updated_at': datetime.now(timezone.utc) + }, + { + 'key': 'allow_registration', + 'value': True, + 'updated_at': datetime.now(timezone.utc) + }, + { + 'key': 'default_user_role', + 'value': 'developer', + 'updated_at': datetime.now(timezone.utc) + }, + { + 'key': 'github_integration_enabled', + 'value': True, + 'updated_at': datetime.now(timezone.utc) + }, + { + 'key': 'notification_settings', + 'value': { + 'email_notifications': True, + 'task_assignments': True, + 'project_updates': True + }, + 'updated_at': datetime.now(timezone.utc) + } + ] + ) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + bind = op.get_bind() + if bind.dialect.name == 'postgresql': + op.drop_constraint('check_valid_role', 'users', type_='check') + + op.drop_table('system_settings') + + op.drop_index('idx_audit_logs_resource', table_name='audit_logs') + op.drop_index('idx_audit_logs_action', table_name='audit_logs') + op.drop_index('idx_audit_logs_actor_time', table_name='audit_logs') + op.drop_table('audit_logs') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/e2f4g5h6i7j8_add_reports_table.py b/backend/migrations/versions/e2f4g5h6i7j8_add_reports_table.py new file mode 100644 index 0000000..01d8427 --- /dev/null +++ b/backend/migrations/versions/e2f4g5h6i7j8_add_reports_table.py @@ -0,0 +1,46 @@ +"""Add reports table for storing generated reports + +Revision ID: e2f4g5h6i7j8 +Revises: 3c8d9e2f1a4b +Create Date: 2026-05-06 15:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e2f4g5h6i7j8' +down_revision = '3c8d9e2f1a4b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('reports', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('report_type', sa.String(length=50), nullable=False), + sa.Column('date_range', sa.String(length=50), nullable=False), + sa.Column('summary', sa.JSON(), nullable=False), + sa.Column('details', sa.JSON(), nullable=False), + sa.Column('generated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_reports_generated_at', 'reports', ['generated_at'], unique=False) + op.create_index('idx_reports_type', 'reports', ['report_type'], unique=False) + op.create_index('idx_reports_user_generated', 'reports', ['user_id', 'generated_at'], unique=False) + op.create_index('idx_reports_user_id', 'reports', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('idx_reports_user_id', table_name='reports') + op.drop_index('idx_reports_user_generated', table_name='reports') + op.drop_index('idx_reports_type', table_name='reports') + op.drop_index('idx_reports_generated_at', table_name='reports') + op.drop_table('reports') + # ### end Alembic commands ### diff --git a/backend/src/api/__init__.py b/backend/src/api/__init__.py index 5074dae..fa6fcca 100644 --- a/backend/src/api/__init__.py +++ b/backend/src/api/__init__.py @@ -17,6 +17,11 @@ notifications_routes.register_routes(api_bp) dashboard_routes.register_routes(api_bp) admin_routes.register_routes(api_bp) +from .routes import report_routes +from .routes import audit_routes + +report_routes.register_routes(api_bp) +audit_routes.register_routes(api_bp) github_routes.register_routes(api_bp) # Add GitHub routes def init_app(app): diff --git a/backend/src/api/controllers/admin_controller.py b/backend/src/api/controllers/admin_controller.py index f207eee..aa58c4b 100644 --- a/backend/src/api/controllers/admin_controller.py +++ b/backend/src/api/controllers/admin_controller.py @@ -4,6 +4,8 @@ from ...db.models import db, User, Project, Task from ..validators.admin_validator import validate_system_settings, validate_user_role_update from ...auth.rbac import Role +from flask_jwt_extended import get_jwt_identity +from ...services import audit_service, settings_service def _safe_query_all(model): @@ -53,20 +55,7 @@ def get_system_stats(): def get_system_settings(): """Controller function to get system settings""" - # This would typically retrieve settings from a database - # For now, return placeholder settings - settings = { - 'app_name': 'DevSync', - 'allow_registration': True, - 'default_user_role': Role.DEVELOPER.value, - 'github_integration_enabled': True, - 'notification_settings': { - 'email_notifications': True, - 'task_assignments': True, - 'project_updates': True - } - } - + settings = settings_service.get_settings() return jsonify({'settings': settings}) def update_system_settings(): @@ -78,8 +67,16 @@ def update_system_settings(): if validation_result: return validation_result - # This would typically update settings in a database - # For now, just return success response + current_user = get_jwt_identity() + user_id = current_user.get('user_id') if isinstance(current_user, dict) else current_user + + settings_service.update_settings(data, user_id) + + audit_service.record( + action='settings_updated', + resource_type='settings', + metadata={'settings_updated': list(data.keys())} + ) return jsonify({ 'message': 'System settings updated successfully', @@ -100,9 +97,17 @@ def update_user_role(user_id): return validation_result # Update user role + old_role = user.role user.role = data['role'] db.session.commit() + audit_service.record( + action='user_role_changed', + resource_type='user', + resource_id=user.id, + metadata={'old_role': old_role, 'new_role': user.role} + ) + return jsonify({ 'message': 'User role updated successfully', 'user': { diff --git a/backend/src/api/controllers/audit_controller.py b/backend/src/api/controllers/audit_controller.py new file mode 100644 index 0000000..3a98064 --- /dev/null +++ b/backend/src/api/controllers/audit_controller.py @@ -0,0 +1,82 @@ +"""Audit Log Controller""" + +from flask import request, jsonify +from ...db.models import AuditLog, User + + +def _build_actor_name_map(logs): + actor_ids = {log.actor_user_id for log in logs if getattr(log, 'actor_user_id', None) is not None} + if not actor_ids: + return {} + + users = User.query.filter(User.id.in_(actor_ids)).all() + return {user.id: user.name for user in users} + + +def _serialize_audit_log(log, actor_name_map=None): + actor_name_map = actor_name_map or {} + actor_user_id = log.actor_user_id + + return { + 'id': log.id, + 'actor_user_id': actor_user_id, + 'actor_name': actor_name_map.get(actor_user_id), + 'actor_role': log.actor_role, + 'action': log.action, + 'resource_type': log.resource_type, + 'resource_id': log.resource_id, + 'ip': log.ip, + 'user_agent': log.user_agent, + 'metadata': log.metadata_info, + 'created_at': log.created_at.isoformat() if log.created_at else None + } + +def get_audit_logs(): + """Get paginated and filtered audit logs""" + action = request.args.get('action') + actor_id = request.args.get('actor') + from_date = request.args.get('from') + to_date = request.args.get('to') + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + + query = AuditLog.query + + if action: + query = query.filter(AuditLog.action.ilike(f'%{action}%')) + if actor_id: + query = query.filter_by(actor_user_id=actor_id) + if from_date: + query = query.filter(AuditLog.created_at >= from_date) + if to_date: + query = query.filter(AuditLog.created_at <= to_date) + + pagination = query.order_by(AuditLog.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + actor_name_map = _build_actor_name_map(pagination.items) + logs_data = [_serialize_audit_log(log, actor_name_map) for log in pagination.items] + + return jsonify({ + 'logs': logs_data, + 'total': pagination.total, + 'pages': pagination.pages, + 'current_page': page + }) + +def get_audit_log_by_id(log_id): + """Get a specific audit log""" + log = AuditLog.query.get_or_404(log_id) + actor_name = None + + if log.actor_user_id is not None: + actor = User.query.get(log.actor_user_id) + actor_name = actor.name if actor else None + + return jsonify({ + 'log': { + **_serialize_audit_log(log, {log.actor_user_id: actor_name} if actor_name else {}), + } + }) diff --git a/backend/src/api/controllers/comments_controller.py b/backend/src/api/controllers/comments_controller.py index f1102ef..4e322e7 100644 --- a/backend/src/api/controllers/comments_controller.py +++ b/backend/src/api/controllers/comments_controller.py @@ -1,10 +1,23 @@ # Comment controller - business logic +import logging from flask import request, jsonify from flask_jwt_extended import get_jwt_identity, get_jwt from ...db.models import db, Comment, Task, User # Changed to relative import from ...auth.rbac import Role # Changed to relative import from ..validators.comment_validator import validate_comment_data # Changed to relative import +from ...services.notification_service import NotificationService + +logger = logging.getLogger(__name__) + + +def _run_notification(callback, *args, **kwargs): + """Create notifications without making the primary comment mutation fail.""" + try: + callback(*args, **kwargs) + except Exception: + db.session.rollback() + logger.exception("Failed to create comment notification") def get_task_comments(task_id): """Controller function to get all comments for a task""" @@ -57,6 +70,24 @@ def add_comment(task_id): db.session.add(new_comment) db.session.commit() + + mentioned_user_ids = data.get('mentioned_user_ids') or data.get('mentioned_users') or [] + recipient_user_ids = { + getattr(task, 'assigned_to', None), + getattr(task, 'created_by', None), + } + recipient_user_ids.discard(None) + + _run_notification( + NotificationService.comment_added_notification, + task_id, + getattr(task, 'title', f'Task {task_id}'), + getattr(task, 'project_id', None), + new_comment.id, + user_id, + mentioned_user_ids, + recipient_user_ids + ) # Get user info for response user = User.query.get(user_id) diff --git a/backend/src/api/controllers/dashboard_controller.py b/backend/src/api/controllers/dashboard_controller.py index 3f24b01..cb3ce98 100644 --- a/backend/src/api/controllers/dashboard_controller.py +++ b/backend/src/api/controllers/dashboard_controller.py @@ -1,7 +1,7 @@ # Dashboard controller - business logic for user dashboards from flask import jsonify from flask_jwt_extended import get_jwt_identity, get_jwt -from ...db.models import db, User, Task, Project # Changed to relative import +from ...db.models import db, User, Task, Project, TaskGitHubLink, GitHubRepository # Changed to relative import from ...auth.rbac import Role # Changed to relative import from datetime import datetime, timedelta import traceback @@ -11,6 +11,8 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +COMPLETED_TASK_STATUSES = {'done', 'completed'} + def _safe_query_all(model): try: @@ -22,6 +24,54 @@ def _safe_query_all(model): def _count(items, predicate): return sum(1 for item in items if predicate(item)) + +def _is_completed_status(status): + return status in COMPLETED_TASK_STATUSES + + +def _is_completed_task(task): + return _is_completed_status(getattr(task, 'status', None)) + + +def _task_to_dashboard_item(task): + project = getattr(task, 'project', None) + return { + 'id': getattr(task, 'id', None), + 'title': getattr(task, 'title', None), + 'description': getattr(task, 'description', None), + 'status': getattr(task, 'status', None), + 'priority': getattr(task, 'priority', None), + 'progress': getattr(task, 'progress', 0) or 0, + 'deadline': getattr(task, 'deadline', None).isoformat() if getattr(task, 'deadline', None) else None, + 'project_id': getattr(task, 'project_id', None), + 'project_name': project.name if project else None, + 'updated_at': getattr(task, 'updated_at', None).isoformat() if getattr(task, 'updated_at', None) else None, + 'created_at': getattr(task, 'created_at', None).isoformat() if getattr(task, 'created_at', None) else None, + } + + +def _github_activity_to_item(link): + task = getattr(link, 'task', None) + repository = getattr(link, 'repository', None) + event_type = 'pull_request' if link.pull_request_number else 'issue' if link.issue_number else 'repository' + label_number = link.pull_request_number or link.issue_number + repo_url = repository.repo_url if repository else None + if repo_url and label_number: + if link.pull_request_number: + repo_url = f'{repo_url.rstrip("/")}/pull/{link.pull_request_number}' + elif link.issue_number: + repo_url = f'{repo_url.rstrip("/")}/issues/{link.issue_number}' + + return { + 'id': link.id, + 'type': event_type, + 'title': task.title if task else 'GitHub linked task', + 'repo': repository.repo_name if repository else 'Unknown repository', + 'url': repo_url, + 'date': link.created_at.isoformat() if link.created_at else None, + 'label': f'#{label_number}' if label_number else 'Linked repository', + } + def get_user_tasks(user_id): """Helper function to get all tasks for a user""" try: @@ -30,15 +80,21 @@ def get_user_tasks(user_id): logger.error(f"Error fetching user tasks: {str(e)}") return [] -def get_tasks_due_soon(user_id): - """Helper function to get tasks due soon for a user""" +def get_tasks_due_soon(user_id=None, project_ids=None): + """Helper function to get tasks due soon for a user or a set of projects""" try: today = datetime.now().date() week_later = today + timedelta(days=7) - return Task.query.filter_by(assigned_to=user_id)\ - .filter(Task.deadline.isnot(None))\ + query = Task.query.filter(Task.deadline.isnot(None))\ .filter(Task.deadline.between(today, week_later))\ - .filter(Task.status != 'done').all() + .filter(~Task.status.in_(COMPLETED_TASK_STATUSES)) + + if project_ids: + query = query.filter(Task.project_id.in_(project_ids)) + elif user_id is not None: + query = query.filter(Task.assigned_to == user_id) + + return query.all() except Exception as e: logger.error(f"Error fetching tasks due soon: {str(e)}") return [] @@ -61,8 +117,11 @@ def get_recent_completed_tasks(user_id, timeframe='month'): days = 30 time_ago = today - timedelta(days=days) - return Task.query.filter_by(assigned_to=user_id, status='done')\ - .filter(Task.updated_at >= time_ago).all() + return Task.query.filter_by(assigned_to=user_id)\ + .filter( + Task.status.in_(COMPLETED_TASK_STATUSES), + Task.updated_at >= time_ago, + ).all() except Exception as e: logger.error(f"Error fetching completed tasks: {str(e)}") return [] @@ -83,7 +142,7 @@ def get_project_tasks_due_soon(project_id): return Task.query.filter_by(project_id=project_id)\ .filter(Task.deadline.isnot(None))\ .filter(Task.deadline.between(today, week_later))\ - .filter(Task.status != 'done').all() + .filter(~Task.status.in_(COMPLETED_TASK_STATUSES)).all() except Exception as e: logger.error(f"Error fetching project tasks due soon: {str(e)}") return [] @@ -134,8 +193,8 @@ def get_user_dashboard(): }, 'tasks': { 'assigned_count': len(assigned_tasks), - 'pending_count': len([t for t in assigned_tasks if t.status != 'done']), - 'completed_count': len([t for t in assigned_tasks if t.status == 'done']), + 'pending_count': len([t for t in assigned_tasks if not _is_completed_task(t)]), + 'completed_count': len([t for t in assigned_tasks if _is_completed_task(t)]), 'due_soon': [{ 'id': task.id, 'title': task.title, @@ -165,7 +224,7 @@ def get_user_dashboard(): dashboard_data['team'] = { 'total_tasks': len(team_tasks), - 'completed_tasks': len([t for t in team_tasks if t.status == 'done']), + 'completed_tasks': len([t for t in team_tasks if _is_completed_task(t)]), 'in_progress_tasks': len([t for t in team_tasks if t.status == 'in_progress']), 'pending_tasks': len([t for t in team_tasks if t.status == 'todo']) } @@ -192,27 +251,64 @@ def get_client_dashboard(): if not user: logger.error(f"User not found: {user_id}") return jsonify({'message': 'User not found'}), 404 - - # Get tasks assigned to this user - assigned_tasks = get_user_tasks(user_id) - + + user_projects = list(user.projects.all()) + if user_role == Role.TEAM_LEAD.value: + seen_project_ids = {project.id for project in user_projects} + for project in Project.query.filter_by(created_by=user_id).all(): + if project.id not in seen_project_ids: + user_projects.append(project) + seen_project_ids.add(project.id) + + project_ids = [project.id for project in user_projects] + is_team_lead = user_role == Role.TEAM_LEAD.value + + # Get tasks in the correct scope for the current role + if is_team_lead: + scoped_tasks = Task.query.filter(Task.project_id.in_(project_ids)).all() if project_ids else [] + tasks_due_soon = get_tasks_due_soon(project_ids=project_ids) + else: + scoped_tasks = get_user_tasks(user_id) + tasks_due_soon = get_tasks_due_soon(user_id=user_id) + + recent_tasks = sorted( + scoped_tasks, + key=lambda task: getattr(task, 'updated_at', None) or getattr(task, 'created_at', None) or datetime.min, + reverse=True, + ) + if not is_team_lead: + recent_tasks = recent_tasks[:10] + task_stats = { - 'total': len(assigned_tasks), - 'todo': _count(assigned_tasks, lambda task: getattr(task, 'status', None) == 'todo'), - 'in_progress': _count(assigned_tasks, lambda task: getattr(task, 'status', None) == 'in_progress'), - 'review': _count(assigned_tasks, lambda task: getattr(task, 'status', None) == 'review'), - 'done': _count(assigned_tasks, lambda task: getattr(task, 'status', None) in {'done', 'completed'}), + 'total': len(scoped_tasks), + 'assigned': len(scoped_tasks), + 'todo': _count(scoped_tasks, lambda task: getattr(task, 'status', None) == 'todo'), + 'in_progress': _count(scoped_tasks, lambda task: getattr(task, 'status', None) == 'in_progress'), + 'review': _count(scoped_tasks, lambda task: getattr(task, 'status', None) == 'review'), + 'done': _count(scoped_tasks, lambda task: getattr(task, 'status', None) in {'done', 'completed'}), + 'due_soon': len(tasks_due_soon), } - - # Get tasks due soon - tasks_due_soon = get_tasks_due_soon(user_id) - - # Get projects user is part of - user_projects = user.projects.all() + + github_activity = [] + try: + recent_links_query = TaskGitHubLink.query.join(Task).outerjoin(GitHubRepository) + if is_team_lead and project_ids: + recent_links_query = recent_links_query.filter(Task.project_id.in_(project_ids)) + else: + recent_links_query = recent_links_query.filter(Task.assigned_to == user_id) + + recent_links = recent_links_query.order_by(TaskGitHubLink.created_at.desc()).limit(5).all() + github_activity = [_github_activity_to_item(link) for link in recent_links] + except Exception as e: + logger.error(f"Error fetching GitHub activity for client dashboard: {str(e)}") + github_activity = [] # Format response data dashboard_data = { + 'taskCounts': task_stats, 'tasks': task_stats, + 'recentTasks': [_task_to_dashboard_item(task) for task in recent_tasks], + 'upcomingDeadlines': [_task_to_dashboard_item(task) for task in tasks_due_soon], 'tasks_due_soon': [{ 'id': task.id, 'title': task.title, @@ -220,6 +316,7 @@ def get_client_dashboard(): 'status': task.status, 'project_id': task.project_id } for task in tasks_due_soon], + 'githubActivity': github_activity, 'projects': [{ 'id': project.id, 'name': project.name, @@ -241,9 +338,9 @@ def get_admin_dashboard(): claims = get_jwt() user_role = claims.get('role') - # Check if user is admin - if user_role != Role.ADMIN.value: - logger.warning(f"Non-admin user {user_id} attempted to access admin dashboard") + # Check if user is admin or team lead + if user_role not in [Role.ADMIN.value, Role.TEAM_LEAD.value]: + logger.warning(f"Unauthorized user {user_id} with role {user_role} attempted to access admin dashboard") return jsonify({'message': 'Unauthorized access'}), 403 # Log the request details for debugging @@ -278,10 +375,11 @@ def get_admin_dashboard(): # Calculate task statistics task_stats = { 'total': len(all_tasks), + 'backlog': len([t for t in all_tasks if t.status == 'backlog']), 'todo': len([t for t in all_tasks if t.status == 'todo']), 'in_progress': len([t for t in all_tasks if t.status == 'in_progress']), 'review': len([t for t in all_tasks if t.status == 'review']), - 'done': len([t for t in all_tasks if t.status == 'done']) + 'done': len([t for t in all_tasks if _is_completed_task(t)]) } # Format response data @@ -333,7 +431,7 @@ def get_project_dashboard(project_id): 'todo': len([t for t in tasks if t.status == 'todo']), 'in_progress': len([t for t in tasks if t.status == 'in_progress']), 'review': len([t for t in tasks if t.status == 'review']), - 'done': len([t for t in tasks if t.status == 'done']) + 'done': len([t for t in tasks if _is_completed_task(t)]) } # Calculate completion percentage diff --git a/backend/src/api/controllers/github_controller.py b/backend/src/api/controllers/github_controller.py index 4c0e2bb..1552191 100644 --- a/backend/src/api/controllers/github_controller.py +++ b/backend/src/api/controllers/github_controller.py @@ -189,7 +189,7 @@ def get_github_repositories(): per_page = request.args.get('per_page', 30, type=int) all_pages_arg = request.args.get('all_pages') fetch_all_pages = all_pages_arg is not None and all_pages_arg.lower() == 'true' - activity_window_days = max(request.args.get('activity_window_days', 7, type=int) or 7, 1) + activity_window_days = max(request.args.get('activity_window_days', 365, type=int) or 365, 1) include_activity_arg = request.args.get('include_activity') # Default to lightweight repository data unless activity is explicitly requested. include_activity = include_activity_arg is not None and include_activity_arg.lower() == 'true' @@ -300,7 +300,7 @@ def get_github_repositories(): repo_info = next((r for r in repos_needing_activity if f"{r['owner']}/{r['repo_name']}" == key), {}) activity_results[key] = { 'open_issues': repo_info.get('fallback_open_issues', 0), - 'open_prs': 0, + 'total_prs': 0, 'recent_commits': 0, } @@ -319,14 +319,14 @@ def get_github_repositories(): if include_activity: activity_metrics = activity_results.get(key, { 'open_issues': repo.get('open_issues_count', 0), - 'open_prs': 0, + 'total_prs': 0, 'recent_commits': 0, }) else: # Return lightweight metrics without making additional GitHub API calls activity_metrics = { 'open_issues': repo.get('open_issues_count', 0), - 'open_prs': 0, + 'total_prs': 0, 'recent_commits': 0, } @@ -372,7 +372,7 @@ def get_github_repositories(): 'default_branch': repo['default_branch'], 'open_issues_count': repo['open_issues_count'], 'open_issues': activity_metrics['open_issues'], - 'open_prs': activity_metrics['open_prs'], + 'total_prs': activity_metrics['total_prs'], 'recent_commits': activity_metrics['recent_commits'], 'last_updated': repo.get('pushed_at') or repo.get('updated_at'), 'stargazers_count': repo.get('stargazers_count', 0), diff --git a/backend/src/api/controllers/notifications_controller.py b/backend/src/api/controllers/notifications_controller.py index 0a5cc2a..d36a959 100644 --- a/backend/src/api/controllers/notifications_controller.py +++ b/backend/src/api/controllers/notifications_controller.py @@ -1,9 +1,11 @@ # Notification controller - business logic +from datetime import datetime, timezone from flask import jsonify, request from flask_jwt_extended import get_jwt_identity -from ...db.models import db, Notification, User # Changed to relative import +from ...db.models import db, Notification # Changed to relative import from ..validators.notification_validator import validate_notification_data # Changed to relative import +from ...services.notification_service import NotificationService def get_user_notifications(): """Controller function to get all notifications for the current user""" @@ -13,13 +15,7 @@ def get_user_notifications(): notifications = Notification.query.filter_by(user_id=user_id)\ .order_by(Notification.created_at.desc()).all() - notifications_data = [{ - 'id': notification.id, - 'content': notification.content, - 'is_read': notification.is_read, - 'task_id': notification.task_id, - 'created_at': notification.created_at.isoformat() if notification.created_at else None - } for notification in notifications] + notifications_data = [notification.to_dict() for notification in notifications] return jsonify({ 'notifications': notifications_data, @@ -35,23 +31,30 @@ def create_notification(): if validation_result: return validation_result - # Create new notification - new_notification = Notification( - content=data['content'], + message = data.get('message') or data.get('content') + title = data.get('title') or message[:80] + notification_type = data.get('notification_type') or data.get('type') or 'general' + task_id = data.get('task_id') + reference_id = data.get('reference_id') or task_id + + # Create new notification and emit it when the target user is connected. + new_notification = NotificationService.send_to_user( user_id=data['user_id'], - task_id=data.get('task_id'), - is_read=data.get('is_read', False) + notification_type=notification_type, + title=title, + message=message, + reference_id=reference_id, + task_id=task_id ) - - db.session.add(new_notification) - db.session.commit() - + + if data.get('is_read') and new_notification: + new_notification.is_read = True + new_notification.read_at = datetime.now(timezone.utc) + db.session.commit() + return jsonify({ 'message': 'Notification created successfully', - 'notification': { - 'id': new_notification.id, - 'content': new_notification.content - } + 'notification': new_notification.to_dict() }), 201 def mark_notification_read(notification_id): @@ -67,6 +70,7 @@ def mark_notification_read(notification_id): # Mark as read notification.is_read = True + notification.read_at = datetime.now(timezone.utc) db.session.commit() return jsonify({'message': 'Notification marked as read'}) @@ -74,10 +78,14 @@ def mark_notification_read(notification_id): def mark_all_notifications_read(): """Controller function to mark all notifications as read for the current user""" user_id = get_jwt_identity()['user_id'] + now = datetime.now(timezone.utc) # Update all unread notifications for this user Notification.query.filter_by(user_id=user_id, is_read=False)\ - .update({Notification.is_read: True}) + .update({ + Notification.is_read: True, + Notification.read_at: now + }) db.session.commit() diff --git a/backend/src/api/controllers/projects_controller.py b/backend/src/api/controllers/projects_controller.py index 25331b4..b510acc 100644 --- a/backend/src/api/controllers/projects_controller.py +++ b/backend/src/api/controllers/projects_controller.py @@ -5,6 +5,7 @@ from ...db.models import db, Project, Task, User # Changed to relative import from ...auth.rbac import Role # Changed to relative import from ..validators.project_validator import validate_project_data # Changed to relative import +from ...services import audit_service import json import time import uuid @@ -58,6 +59,11 @@ def get_all_projects(): 'status': project.status, 'github_repo': project.github_repo, 'created_by': project.created_by, + 'team_members': [{ + 'id': member.id, + 'name': member.name, + 'role': member.role, + } for member in _relationship_items(project.team_members)], 'created_at': project.created_at.isoformat() if project.created_at else None, 'updated_at': project.updated_at.isoformat() if project.updated_at else None } for project in projects] @@ -193,6 +199,12 @@ def create_project(): new_project.team_members.append(member) db.session.commit() + + audit_service.record( + action='project_created', + resource_type='project', + resource_id=new_project.id + ) # region agent log _debug_log( 'H1-H2', @@ -265,6 +277,12 @@ def delete_project(project_id): db.session.delete(project) db.session.commit() + audit_service.record( + action='project_deleted', + resource_type='project', + resource_id=project_id + ) + # Updated to return 204 return '', 204 diff --git a/backend/src/api/controllers/report_controller.py b/backend/src/api/controllers/report_controller.py new file mode 100644 index 0000000..f3c89bd --- /dev/null +++ b/backend/src/api/controllers/report_controller.py @@ -0,0 +1,141 @@ +# Report controller - business logic for report operations + +from flask import request, jsonify +from flask_jwt_extended import get_jwt_identity, get_jwt +from ...db.models import db, Report, User +from ...auth.rbac import Role +from ..validators.report_validator import validate_report_data + +def save_report(): + """Controller function to save a generated report""" + user_id = get_jwt_identity()['user_id'] + data = request.get_json() + + # Validate report data + validation_result = validate_report_data(data) + if validation_result: + return validation_result + + try: + # Create new report record + report = Report( + user_id=user_id, + report_type=data['report_type'], + date_range=data['date_range'], + summary=data['summary'], + details=data['details'] + ) + + db.session.add(report) + db.session.commit() + + return jsonify({ + 'message': 'Report saved successfully', + 'report': report.to_dict() + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({'message': f'Failed to save report: {str(e)}'}), 500 + +def get_reports(): + """Controller function to get saved reports for the current user or team""" + user_id = get_jwt_identity()['user_id'] + claims = get_jwt() + user_role = claims.get('role') + + # Parse query parameters for filtering + report_type = request.args.get('type') + date_range = request.args.get('dateRange') + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + + try: + query = Report.query + + # Filter by user role + if user_role in [Role.ADMIN.value, Role.TEAM_LEAD.value]: + # Admins and team leads can see all reports + pass + else: + # Developers can only see their own reports + query = query.filter_by(user_id=user_id) + + # Apply optional filters + if report_type: + query = query.filter_by(report_type=report_type) + if date_range: + query = query.filter_by(date_range=date_range) + + # Sort by most recent first + query = query.order_by(Report.generated_at.desc()) + + # Paginate results + paginated = query.paginate(page=page, per_page=per_page, error_out=False) + + reports = [report.to_dict() for report in paginated.items] + + return jsonify({ + 'message': 'Reports retrieved successfully', + 'reports': reports, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': paginated.total, + 'pages': paginated.pages + } + }), 200 + + except Exception as e: + return jsonify({'message': f'Failed to retrieve reports: {str(e)}'}), 500 + +def get_report_by_id(report_id): + """Controller function to get a specific report by ID""" + user_id = get_jwt_identity()['user_id'] + claims = get_jwt() + user_role = claims.get('role') + + try: + report = Report.query.get(report_id) + if not report: + return jsonify({'message': 'Report not found'}), 404 + + # Check permissions + if user_role not in [Role.ADMIN.value, Role.TEAM_LEAD.value]: + if report.user_id != user_id: + return jsonify({'message': 'Unauthorized access to this report'}), 403 + + return jsonify({ + 'message': 'Report retrieved successfully', + 'report': report.to_dict() + }), 200 + + except Exception as e: + return jsonify({'message': f'Failed to retrieve report: {str(e)}'}), 500 + +def delete_report(report_id): + """Controller function to delete a report""" + user_id = get_jwt_identity()['user_id'] + claims = get_jwt() + user_role = claims.get('role') + + try: + report = Report.query.get(report_id) + if not report: + return jsonify({'message': 'Report not found'}), 404 + + # Check permissions - can only delete own reports unless admin + if user_role not in [Role.ADMIN.value]: + if report.user_id != user_id: + return jsonify({'message': 'Unauthorized to delete this report'}), 403 + + db.session.delete(report) + db.session.commit() + + return jsonify({ + 'message': 'Report deleted successfully' + }), 200 + + except Exception as e: + db.session.rollback() + return jsonify({'message': f'Failed to delete report: {str(e)}'}), 500 diff --git a/backend/src/api/controllers/tasks_controller.py b/backend/src/api/controllers/tasks_controller.py index f3cbed2..f02cb65 100644 --- a/backend/src/api/controllers/tasks_controller.py +++ b/backend/src/api/controllers/tasks_controller.py @@ -1,12 +1,17 @@ # Task controller - business logic +import logging from flask import request, jsonify from flask_jwt_extended import get_jwt_identity, get_jwt from ...db.models import db, Task, User # Changed to relative import from ...auth.rbac import Role # Changed to relative import from ..validators.task_validator import validate_task_data # Changed to relative import +from ...services import audit_service +from ...services.notification_service import NotificationService from unittest.mock import Mock +logger = logging.getLogger(__name__) + MEMBER_ROLES = { Role.DEVELOPER.value, Role.TEAM_LEAD.value, @@ -18,6 +23,25 @@ } +def _run_notification(callback, *args, **kwargs): + """Create notifications without making the primary task mutation fail.""" + try: + callback(*args, **kwargs) + except Exception: + db.session.rollback() + logger.exception("Failed to create task notification") + + +def _coerce_int(value): + if value in (None, ''): + return None + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return value + + def _task_value(task, field, default=None): value = vars(task).get(field, getattr(task, field, default)) if isinstance(value, Mock): @@ -45,6 +69,7 @@ def _serialize_task(task): 'status': _task_value(task, 'status'), 'priority': _task_value(task, 'priority', 'medium'), 'progress': _task_value(task, 'progress', 0), + 'project_id': _task_value(task, 'project_id'), 'assigned_to': _task_value(task, 'assigned_to'), 'created_by': _task_value(task, 'created_by'), 'deadline': _task_datetime(task, 'deadline'), @@ -74,15 +99,8 @@ def get_all_tasks(): if created_by: query = query.filter(Task.created_by == created_by) - # Apply role-based filtering - if user_role in TASK_MANAGER_ROLES: - # Team leads and admins can see all tasks for assignment and reporting. - tasks = query.all() - else: - # Developers can only see tasks assigned to them or created by them. - tasks = query.filter( - (Task.assigned_to == user_id) | (Task.created_by == user_id) - ).all() + # Apply role-based filtering - Developers can now see all tasks as well + tasks = query.all() # Convert tasks to JSON response tasks_data = [_serialize_task(task) for task in tasks] @@ -97,11 +115,7 @@ def get_task_by_id(task_id): task = Task.query.get_or_404(task_id) - # Apply role-based access control - if (user_role in MEMBER_ROLES and - task.assigned_to != user_id and task.created_by != user_id): - return jsonify({'message': 'You do not have permission to view this task'}), 403 - + # Authenticated users can view any task # Format task data task_data = _serialize_task(task) @@ -150,34 +164,42 @@ def create_new_task(): if not identity or 'user_id' not in identity: return jsonify({'message': 'Invalid authentication token'}), 401 user_id = identity['user_id'] + user_role = get_jwt().get('role') - assigned_to = data.get('assigned_to') - if assigned_to in (None, ''): + assigned_to = _coerce_int(data.get('assigned_to')) + if user_role == Role.DEVELOPER.value: + assigned_to = user_id + elif assigned_to is None and user_role in TASK_MANAGER_ROLES: assigned_to = None - elif isinstance(assigned_to, str) and assigned_to.isdigit(): - assigned_to = int(assigned_to) - project_id = data.get('project_id') - if project_id in (None, ''): - project_id = None - elif isinstance(project_id, str) and project_id.isdigit(): - project_id = int(project_id) + project_id = _coerce_int(data.get('project_id')) + + if user_role == Role.DEVELOPER.value and data.get('assigned_to') not in (None, '', user_id, str(user_id)): + return jsonify({'message': 'Developers can only create tasks assigned to themselves'}), 403 # Create new task - new_task = Task( - title=data['title'], - description=data['description'], - status=data['status'], - priority=data.get('priority', 'medium'), - progress=data.get('progress', 0), - assigned_to=assigned_to, - created_by=user_id, - deadline=data.get('deadline'), - project_id=project_id - ) + new_task = Task() + new_task.title = data['title'] + new_task.description = data['description'] + new_task.status = data['status'] + new_task.priority = data.get('priority', 'medium') + new_task.progress = data.get('progress', 0) + new_task.assigned_to = assigned_to + new_task.created_by = user_id + new_task.deadline = data.get('deadline') + new_task.project_id = project_id db.session.add(new_task) db.session.commit() + + _run_notification( + NotificationService.task_created_notification, + new_task.id, + new_task.title, + new_task.project_id, + user_id, + new_task.assigned_to + ) return jsonify({ 'message': 'Task created successfully', @@ -196,16 +218,14 @@ def update_task_by_id(task_id): user_role = claims.get('role') task = Task.query.get_or_404(task_id) + old_assignee_id = task.assigned_to # Check if user has permission to update this task - can_update_task = user_role == Role.ADMIN.value or task.assigned_to == user_id + # Admins and Team Leads can update any task (can_update_any_task) + can_update_task = user_role in TASK_MANAGER_ROLES or task.assigned_to == user_id can_assign_task = user_role in TASK_MANAGER_ROLES - if not can_update_task and not can_assign_task: - return jsonify({'message': 'You can only update tasks assigned to you'}), 403 - - non_assignment_fields = {'title', 'description', 'status', 'progress', 'priority'} & set(data.keys()) - if non_assignment_fields and not can_update_task: + if not can_update_task: return jsonify({'message': 'You can only update tasks assigned to you'}), 403 # Update allowed fields @@ -220,12 +240,24 @@ def update_task_by_id(task_id): if 'priority' in data: task.priority = data['priority'] - if 'assigned_to' in data and can_assign_task: - task.assigned_to = data['assigned_to'] - elif 'assigned_to' in data: - return jsonify({'message': 'You do not have permission to assign tasks'}), 403 + if 'assigned_to' in data: + # Only TL or Admins can change the assignee + if can_assign_task: + task.assigned_to = _coerce_int(data['assigned_to']) + elif _coerce_int(data['assigned_to']) != task.assigned_to: + return jsonify({'message': 'You do not have permission to reassign tasks'}), 403 db.session.commit() + + _run_notification( + NotificationService.task_updated_notification, + task.id, + task.title, + task.project_id, + user_id, + old_assignee_id, + task.assigned_to + ) return jsonify({ 'message': 'Task updated successfully', @@ -240,9 +272,23 @@ def update_task_by_id(task_id): def delete_task_by_id(task_id): """Controller function to delete a task""" + user_id = get_jwt_identity()['user_id'] + claims = get_jwt() + user_role = claims.get('role') + task = Task.query.get_or_404(task_id) + + can_delete_task = user_role in TASK_MANAGER_ROLES or task.assigned_to == user_id or task.created_by == user_id + if not can_delete_task: + return jsonify({'message': 'You can only delete tasks assigned to you'}), 403 db.session.delete(task) db.session.commit() + audit_service.record( + action='task_deleted', + resource_type='task', + resource_id=task_id + ) + return jsonify({'message': 'Task deleted successfully'}) diff --git a/backend/src/api/controllers/users_controller.py b/backend/src/api/controllers/users_controller.py index 1e40523..815b5d9 100644 --- a/backend/src/api/controllers/users_controller.py +++ b/backend/src/api/controllers/users_controller.py @@ -5,6 +5,7 @@ from ...db.models import db, User # Changed to relative import from ...auth.helpers import hash_password, verify_password # Changed to relative import from ..validators.user_validator import validate_user_data, validate_profile_update # Changed to relative import +from ...services import audit_service def get_all_users(): """Controller function to get all users""" @@ -22,6 +23,47 @@ def get_all_users(): return jsonify({'users': users_data}) +def create_user(): + """Controller function to create a new user (admin only)""" + data = request.get_json() + + # Validate user data + validation_result = validate_user_data(data) + if validation_result: + return validation_result + + # Check if email is already taken + if User.query.filter_by(email=data['email']).first(): + return jsonify({'message': 'Email already in use'}), 409 + + # Create new user + new_user = User( + name=data['name'], + email=data['email'], + password=hash_password(data.get('password')), + role=data.get('role', 'developer') + ) + + db.session.add(new_user) + db.session.commit() + + audit_service.record( + action='user_created', + resource_type='user', + resource_id=new_user.id, + metadata={'role': new_user.role} + ) + + return jsonify({ + 'message': 'User created successfully', + 'user': { + 'id': new_user.id, + 'name': new_user.name, + 'email': new_user.email, + 'role': new_user.role + } + }), 201 + def get_user_by_id(user_id): """Controller function to get a specific user""" user = User.query.get_or_404(user_id) @@ -86,6 +128,12 @@ def delete_user(user_id): db.session.delete(user) db.session.commit() + audit_service.record( + action='user_deleted', + resource_type='user', + resource_id=user_id + ) + return jsonify({'message': 'User deleted successfully'}) def get_current_user_profile(): diff --git a/backend/src/api/routes/__init__.py b/backend/src/api/routes/__init__.py index e9ca72c..1f1dd3b 100644 --- a/backend/src/api/routes/__init__.py +++ b/backend/src/api/routes/__init__.py @@ -12,6 +12,7 @@ from . import dashboard_routes from . import admin_routes from . import github_routes +from . import audit_routes """API routes registration""" @@ -25,7 +26,8 @@ dashboard_routes, admin_routes, notifications_routes, - github_routes + github_routes, + audit_routes ) def register_all_routes(app): @@ -42,6 +44,7 @@ def register_all_routes(app): admin_routes.register_routes(api_bp) notifications_routes.register_routes(api_bp) github_routes.register_routes(api_bp) + audit_routes.register_routes(api_bp) # Register blueprint with app app.register_blueprint(api_bp, url_prefix='/api') diff --git a/backend/src/api/routes/admin_routes.py b/backend/src/api/routes/admin_routes.py index 1faa8c7..a3621b8 100644 --- a/backend/src/api/routes/admin_routes.py +++ b/backend/src/api/routes/admin_routes.py @@ -11,13 +11,23 @@ from ..middlewares import admin_required from ..middlewares.validation_middleware import validate_json from ..middlewares.rate_limiter import rate_limit +from ..controllers.users_controller import get_all_users, create_user, update_user, delete_user +from ...auth.rbac import Role, role_at_least def register_routes(bp): """Register all admin routes with the provided Blueprint""" - @bp.route('/admin/stats', methods=['GET']) + @bp.route('/admin/users', methods=['POST']) @jwt_required() @admin_required() + @validate_json() + def admin_create_user(): + """Route to create a user""" + return create_user() + + @bp.route('/admin/stats', methods=['GET']) + @jwt_required() + @role_at_least(Role.TEAM_LEAD) @rate_limit(requests_per_window=20, window_seconds=60) def system_stats(): """Route to get system statistics""" @@ -48,3 +58,25 @@ def update_settings(): def user_role_update(user_id): """Route to update a user's role""" return update_user_role(user_id) + + @bp.route('/admin/users', methods=['GET']) + @jwt_required() + @role_at_least(Role.TEAM_LEAD) + def admin_get_all_users(): + """Route to get all users""" + return get_all_users() + + @bp.route('/admin/users/', methods=['PUT']) + @jwt_required() + @admin_required() + @validate_json() + def admin_update_user(user_id): + """Route to update a user""" + return update_user(user_id) + + @bp.route('/admin/users/', methods=['DELETE']) + @jwt_required() + @admin_required() + def admin_delete_user(user_id): + """Route to delete a user""" + return delete_user(user_id) diff --git a/backend/src/api/routes/audit_routes.py b/backend/src/api/routes/audit_routes.py new file mode 100644 index 0000000..f892134 --- /dev/null +++ b/backend/src/api/routes/audit_routes.py @@ -0,0 +1,23 @@ +"""Audit Log API routes""" + +from flask import request +from flask_jwt_extended import jwt_required +from ..controllers.audit_controller import get_audit_logs, get_audit_log_by_id +from ..middlewares import admin_required + +def register_routes(bp): + """Register all audit routes with the provided Blueprint""" + + @bp.route('/admin/audit-logs', methods=['GET']) + @jwt_required() + @admin_required() + def get_audit_logs_route(): + """Route to get all audit logs (Admin only)""" + return get_audit_logs() + + @bp.route('/admin/audit-logs/', methods=['GET']) + @jwt_required() + @admin_required() + def get_audit_log_route(log_id): + """Route to get a single audit log (Admin only)""" + return get_audit_log_by_id(log_id) diff --git a/backend/src/api/routes/auth_routes.py b/backend/src/api/routes/auth_routes.py index 31e7e81..0d4b85d 100644 --- a/backend/src/api/routes/auth_routes.py +++ b/backend/src/api/routes/auth_routes.py @@ -6,6 +6,7 @@ from ...auth.auth import login, register_user, refresh_token, logout_user, get_token # Added get_token from ..middlewares.validation_middleware import validate_json # Changed to relative import from ..validators.auth_validator import validate_login_data, validate_registration_data # Changed to relative import +from ...auth.rbac import ROLE_PERMISSIONS def register_routes(bp): """Register all authentication routes with the provided Blueprint""" @@ -58,3 +59,17 @@ def token_route(): if validation_error: return validation_error return get_token() + + @bp.route('/auth/permissions', methods=['GET']) + @jwt_required() + def get_permissions(): + """Route to get permissions for the current user""" + from flask_jwt_extended import get_jwt + claims = get_jwt() + role = claims.get('role', 'developer') + permissions = ROLE_PERMISSIONS.get(role, []) + from flask import jsonify + return jsonify({ + 'role': role, + 'permissions': permissions + }) diff --git a/backend/src/api/routes/comments_routes.py b/backend/src/api/routes/comments_routes.py index 079ac2d..2089ec4 100644 --- a/backend/src/api/routes/comments_routes.py +++ b/backend/src/api/routes/comments_routes.py @@ -10,8 +10,7 @@ ) from ..middlewares.validation_middleware import validate_json from ..middlewares import role_required -from ...auth.rbac import Role - +from ...auth.rbac import Role, require_permission AUTHENTICATED_ROLES = [Role.DEVELOPER, Role.TEAM_LEAD, Role.ADMIN] def register_routes(bp): @@ -27,7 +26,8 @@ def comments_list(task_id): @bp.route('/tasks//comments', methods=['POST']) @jwt_required() @role_required(AUTHENTICATED_ROLES) - @validate_json() # Fixed: added parentheses + @require_permission('can_comment_on_tasks') + @validate_json() def create_comment(task_id): """Route to add a comment to a task""" return add_comment(task_id) diff --git a/backend/src/api/routes/dashboard_routes.py b/backend/src/api/routes/dashboard_routes.py index 7142f88..76735c8 100644 --- a/backend/src/api/routes/dashboard_routes.py +++ b/backend/src/api/routes/dashboard_routes.py @@ -31,7 +31,7 @@ def client_dashboard(): @bp.route('/dashboard/admin', methods=['GET']) @jwt_required() - @role_required(Role.ADMIN) + @role_required([Role.ADMIN, Role.TEAM_LEAD]) def admin_dashboard(): """Route to get admin-specific dashboard data""" return get_admin_dashboard() diff --git a/backend/src/api/routes/github_routes.py b/backend/src/api/routes/github_routes.py index 258b8d0..174a337 100644 --- a/backend/src/api/routes/github_routes.py +++ b/backend/src/api/routes/github_routes.py @@ -20,6 +20,8 @@ delete_task_github_link, ) from ..middlewares.validation_middleware import validate_json +from ..middlewares import role_required +from ...auth.rbac import Role, require_permission from ...db.models import db, User, GitHubToken from ...services.github_client import GitHubClient @@ -133,6 +135,8 @@ def repositories_list(): @bp.route('/github/repositories', methods=['POST']) @jwt_required() + @role_required([Role.ADMIN]) + @require_permission('can_link_github_repos') @validate_json() def add_repository(): """Route to add a GitHub repository for tracking""" diff --git a/backend/src/api/routes/notifications_routes.py b/backend/src/api/routes/notifications_routes.py index 99b21ee..1739ff2 100644 --- a/backend/src/api/routes/notifications_routes.py +++ b/backend/src/api/routes/notifications_routes.py @@ -10,6 +10,7 @@ delete_notification ) from ..middlewares.validation_middleware import validate_json +from ...auth.rbac import require_permission def register_routes(bp): """Register all notification routes with the provided Blueprint""" @@ -41,6 +42,7 @@ def mark_all_read(): @bp.route('/notifications/', methods=['DELETE']) @jwt_required() + @require_permission('can_manage_personal_notifications') def delete_notification_route(notification_id): """Route to delete a notification""" return delete_notification(notification_id) diff --git a/backend/src/api/routes/projects_routes.py b/backend/src/api/routes/projects_routes.py index 2947239..763c583 100644 --- a/backend/src/api/routes/projects_routes.py +++ b/backend/src/api/routes/projects_routes.py @@ -32,7 +32,7 @@ def projects_list(): @bp.route('/projects', methods=['POST']) @jwt_required() - @role_required([Role.ADMIN]) + @role_required([Role.TEAM_LEAD, Role.ADMIN]) @validate_json() @log_api_usage() @log_request() @@ -50,7 +50,7 @@ def get_project(project_id): @bp.route('/projects/', methods=['PUT']) @jwt_required() - @role_required([Role.ADMIN]) + @role_required([Role.TEAM_LEAD, Role.ADMIN]) @validate_json() @log_api_usage() def update_project_route(project_id): @@ -59,7 +59,7 @@ def update_project_route(project_id): @bp.route('/projects/', methods=['DELETE']) @jwt_required() - @role_required([Role.ADMIN]) + @role_required([Role.TEAM_LEAD, Role.ADMIN]) @log_api_usage() def delete_project_route(project_id): """Route to delete a project""" diff --git a/backend/src/api/routes/report_routes.py b/backend/src/api/routes/report_routes.py new file mode 100644 index 0000000..97b61a1 --- /dev/null +++ b/backend/src/api/routes/report_routes.py @@ -0,0 +1,59 @@ +"""Report API routes""" + +from flask import request +from flask_jwt_extended import jwt_required +from ..controllers.report_controller import ( + save_report, + get_reports, + get_report_by_id, + delete_report +) +from ..middlewares.validation_middleware import validate_json +from ..middlewares import role_required +from ..middlewares.api_usage_logger import log_api_usage +from ..middlewares.request_logger import log_request +from ...auth.rbac import Role + +# Only Team Lead and Admin can generate and view reports +REPORT_ROLES = [Role.TEAM_LEAD, Role.ADMIN] +AUTHENTICATED_ROLES = [Role.DEVELOPER, Role.TEAM_LEAD, Role.ADMIN] + +def register_routes(bp): + """Register all report routes with the provided Blueprint""" + + @bp.route('/reports', methods=['POST']) + @jwt_required() + @role_required(REPORT_ROLES) + @validate_json() + @log_api_usage() + @log_request() + def save_report_route(): + """Route to save a generated report""" + return save_report() + + @bp.route('/reports', methods=['GET']) + @jwt_required() + @role_required(REPORT_ROLES) + @log_api_usage() + @log_request() + def get_reports_route(): + """Route to get saved reports""" + return get_reports() + + @bp.route('/reports/', methods=['GET']) + @jwt_required() + @role_required(REPORT_ROLES) + @log_api_usage() + @log_request() + def get_report_route(report_id): + """Route to get a specific report""" + return get_report_by_id(report_id) + + @bp.route('/reports/', methods=['DELETE']) + @jwt_required() + @role_required(REPORT_ROLES) + @log_api_usage() + @log_request() + def delete_report_route(report_id): + """Route to delete a report""" + return delete_report(report_id) diff --git a/backend/src/api/routes/tasks_routes.py b/backend/src/api/routes/tasks_routes.py index 8efc275..4c6ae05 100644 --- a/backend/src/api/routes/tasks_routes.py +++ b/backend/src/api/routes/tasks_routes.py @@ -27,7 +27,7 @@ def tasks_list(): @bp.route('/tasks', methods=['POST']) @jwt_required() - @role_required([Role.TEAM_LEAD, Role.ADMIN]) + @role_required([Role.DEVELOPER, Role.TEAM_LEAD, Role.ADMIN]) @validate_json() def create_task(): """Route to create a new task""" @@ -50,7 +50,7 @@ def update_task(task_id): @bp.route('/tasks/', methods=['DELETE']) @jwt_required() - @role_required([Role.ADMIN]) + @role_required([Role.DEVELOPER, Role.TEAM_LEAD, Role.ADMIN]) def delete_task(task_id): """Route to delete a task""" return delete_task_by_id(task_id) diff --git a/backend/src/api/routes/users_routes.py b/backend/src/api/routes/users_routes.py index 1929ac8..2e7564e 100644 --- a/backend/src/api/routes/users_routes.py +++ b/backend/src/api/routes/users_routes.py @@ -1,7 +1,7 @@ """User API routes""" from flask import request -from flask_jwt_extended import jwt_required +from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from ..controllers.users_controller import ( get_all_users, get_user_by_id, @@ -12,14 +12,14 @@ ) from ..middlewares.validation_middleware import validate_json from ..middlewares import admin_required, role_required -from ...auth.rbac import Role +from ...auth.rbac import Role, _role_level, role_at_least def register_routes(bp): """Register all user routes with the provided Blueprint""" @bp.route('/users', methods=['GET']) @jwt_required() - @role_required([Role.ADMIN]) + @role_at_least(Role.DEVELOPER) def users_list(): """Route to get all users""" return get_all_users() @@ -27,7 +27,22 @@ def users_list(): @bp.route('/users/', methods=['GET']) @jwt_required() def get_user(user_id): - """Route to get a specific user""" + """Route to get a specific user. + + Developers may only view their own profile. + Team leads and admins may view any profile. + """ + identity = get_jwt_identity() + claims = get_jwt() + caller_id = identity.get('user_id') if isinstance(identity, dict) else identity + caller_role = claims.get('role', '') + + is_self = int(caller_id) == int(user_id) + is_elevated = _role_level(caller_role) >= _role_level(Role.TEAM_LEAD.value) + + if not is_self and not is_elevated: + return {'message': 'You can only view your own profile'}, 403 + return get_user_by_id(user_id) @bp.route('/users/', methods=['PUT']) diff --git a/backend/src/api/validators/admin_validator.py b/backend/src/api/validators/admin_validator.py index c617db4..f350d5d 100644 --- a/backend/src/api/validators/admin_validator.py +++ b/backend/src/api/validators/admin_validator.py @@ -10,23 +10,21 @@ def validate_system_settings(data): return jsonify({'message': 'No settings provided'}), 400 # Validate app_name if provided - if 'app_name' in data and (len(data['app_name']) < 3 or len(data['app_name']) > 50): - return jsonify({'message': 'App name must be between 3 and 50 characters'}), 400 - - # Validate allow_registration if provided - if 'allow_registration' in data and not isinstance(data['allow_registration'], bool): - return jsonify({'message': 'allow_registration must be a boolean value'}), 400 - + if 'app_name' in data: + if not isinstance(data['app_name'], str) or len(data['app_name']) < 3: + return jsonify({'message': 'App name must be between 3 and 100 characters'}), 400 + + # Validate boolean fields + for bool_field in ['allow_registration', 'github_integration_enabled']: + if bool_field in data and not isinstance(data[bool_field], bool): + return jsonify({'message': f'{bool_field} must be a boolean value'}), 400 + # Validate default_user_role if provided if 'default_user_role' in data: valid_roles = [role.value for role in Role] if data['default_user_role'] not in valid_roles: return jsonify({'message': f'Default user role must be one of: {", ".join(valid_roles)}'}), 400 - # Validate github_integration_enabled if provided - if 'github_integration_enabled' in data and not isinstance(data['github_integration_enabled'], bool): - return jsonify({'message': 'github_integration_enabled must be a boolean value'}), 400 - # Validate notification_settings if provided if 'notification_settings' in data: if not isinstance(data['notification_settings'], dict): @@ -36,7 +34,7 @@ def validate_system_settings(data): for key, value in data['notification_settings'].items(): if not isinstance(value, bool): return jsonify({'message': f'Notification setting "{key}" must be a boolean value'}), 400 - + # If validation passes, return None return None diff --git a/backend/src/api/validators/auth_validator.py b/backend/src/api/validators/auth_validator.py index 4a2c37c..d719751 100644 --- a/backend/src/api/validators/auth_validator.py +++ b/backend/src/api/validators/auth_validator.py @@ -9,29 +9,29 @@ def validate_login_data(data): if not all(k in data for k in ['email', 'password']): return jsonify({'message': 'Email and password required'}), 400 - # Validate email format - email_pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$' + # Relaxed email pattern to support plus signs, subdomains, etc. + email_pattern = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' if not re.match(email_pattern, data['email']): return jsonify({'message': 'Invalid email format'}), 400 return None def validate_registration_data(data): - """Validate user registration data""" - if not all(k in data for k in ['name', 'email', 'password', 'role']): + """Validate user registration data. + + Note: the ``role`` field is **not** required. The backend always forces + new registrations to the ``developer`` role for security. + """ + if not all(k in data for k in ['name', 'email', 'password']): return jsonify({'message': 'Missing required fields'}), 400 - - # Validate email format - email_pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$' + + # Relaxed email pattern to support plus signs, subdomains, etc. + email_pattern = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' if not re.match(email_pattern, data['email']): return jsonify({'message': 'Invalid email format'}), 400 - + # Validate password length if len(data['password']) < 8: return jsonify({'message': 'Password must be at least 8 characters long'}), 400 - - valid_roles = [role.value for role in Role] - if data['role'] not in valid_roles: - return jsonify({'message': f'Role must be one of: {", ".join(valid_roles)}'}), 400 - + return None diff --git a/backend/src/api/validators/notification_validator.py b/backend/src/api/validators/notification_validator.py index c7bc267..98109f6 100644 --- a/backend/src/api/validators/notification_validator.py +++ b/backend/src/api/validators/notification_validator.py @@ -5,12 +5,20 @@ def validate_notification_data(data): """Validate notification data from requests""" # Check for required fields - if not all(k in data for k in ['content', 'user_id']): + if not data or 'user_id' not in data or not (data.get('content') or data.get('message')): return jsonify({'message': 'Missing required fields'}), 400 - # Validate content - if len(data['content']) < 1 or len(data['content']) > 500: - return jsonify({'message': 'Notification content must be between 1 and 500 characters'}), 400 + # Validate message/content + message = data.get('message') or data.get('content') + if not isinstance(message, str) or len(message) < 1 or len(message) > 500: + return jsonify({'message': 'Notification message must be between 1 and 500 characters'}), 400 + + if 'title' in data and (not isinstance(data['title'], str) or len(data['title']) > 255): + return jsonify({'message': 'Notification title must be a string up to 255 characters'}), 400 + + notification_type = data.get('notification_type') or data.get('type') + if notification_type is not None and (not isinstance(notification_type, str) or len(notification_type) > 50): + return jsonify({'message': 'Notification type must be a string up to 50 characters'}), 400 # Validate user_id if not isinstance(data['user_id'], int): diff --git a/backend/src/api/validators/report_validator.py b/backend/src/api/validators/report_validator.py new file mode 100644 index 0000000..268543f --- /dev/null +++ b/backend/src/api/validators/report_validator.py @@ -0,0 +1,33 @@ +# Report data validation + +from flask import jsonify + +VALID_REPORT_TYPES = ['tasks', 'developers', 'github'] +VALID_DATE_RANGES = ['week', 'month', 'quarter', 'year'] + +def validate_report_data(data): + """Validate report save request data""" + # Check for required fields + required_fields = ['report_type', 'date_range', 'summary', 'details'] + for field in required_fields: + if field not in data: + return jsonify({'message': f'Missing required {field} field'}), 400 + + # Validate report_type + if data['report_type'] not in VALID_REPORT_TYPES: + return jsonify({'message': f'Invalid report_type. Must be one of: {", ".join(VALID_REPORT_TYPES)}'}), 400 + + # Validate date_range + if data['date_range'] not in VALID_DATE_RANGES: + return jsonify({'message': f'Invalid date_range. Must be one of: {", ".join(VALID_DATE_RANGES)}'}), 400 + + # Validate summary is a dictionary + if not isinstance(data['summary'], dict): + return jsonify({'message': 'Summary must be a JSON object'}), 400 + + # Validate details is a list + if not isinstance(data['details'], list): + return jsonify({'message': 'Details must be a JSON array'}), 400 + + # If validation passes, return None + return None diff --git a/backend/src/auth/auth.py b/backend/src/auth/auth.py index 87606fb..a93c968 100644 --- a/backend/src/auth/auth.py +++ b/backend/src/auth/auth.py @@ -4,55 +4,67 @@ from flask_jwt_extended import ( jwt_required, get_jwt_identity, get_jwt, set_access_cookies, set_refresh_cookies, unset_jwt_cookies, - create_access_token # Added missing import + create_access_token ) from sqlalchemy.exc import IntegrityError from ..db.models import db, User # Fix import path from .helpers import hash_password, verify_password, generate_tokens from .rbac import Role +from ..services import audit_service, settings_service -auth_bp = Blueprint('auth', __name__) +def register_user(): + """Function to register a new user. -def _validate_role(role): - valid_roles = [item.value for item in Role] - if role not in valid_roles: - return jsonify({'message': f'Role must be one of: {", ".join(valid_roles)}'}), 400 - return None - -@auth_bp.route('/register', methods=['POST']) -def register(): + Security: the role field from the request body is **ignored**. + All new accounts are created with the 'developer' role. + Admins can promote users after registration via PUT /admin/users//role. + """ data = request.get_json() - - # Validate required fields - if not all(k in data for k in ['name', 'email', 'password', 'role']): + + # Validate required fields (role is no longer required from the client) + if not all(k in data for k in ['name', 'email', 'password']): return jsonify({'message': 'Missing required fields'}), 400 - role_validation = _validate_role(data['role']) - if role_validation: - return role_validation - # Check if email already exists existing_user = User.query.filter_by(email=data['email']).first() if existing_user: return jsonify({'message': 'Email already registered'}), 409 - - # Create new user + + # If this is the very first user, automatically make them an admin + user_count = User.query.count() + if user_count == 0: + forced_role = Role.ADMIN.value + print(f"First user registration detected! Automatically granting admin role to {data['email']}") + else: + # Otherwise get default role from settings + forced_role = settings_service.get_default_role() + + print(f"Registering user: {data['email']} with role: {forced_role} (ignoring any client-supplied role)") + try: new_user = User( name=data['name'], email=data['email'], password=hash_password(data['password']), - role=data['role'] + role=forced_role ) - + db.session.add(new_user) db.session.commit() - + + # Record audit log + audit_service.record( + action='user_registered', + actor={'user_id': new_user.id, 'role': new_user.role}, + resource_type='user', + resource_id=new_user.id + ) + # Generate tokens for the new user tokens = generate_tokens(new_user.id, {'role': new_user.role}) - + # Create response with tokens resp = jsonify({ 'message': 'User registered successfully', @@ -60,21 +72,22 @@ def register(): 'id': new_user.id, 'name': new_user.name, 'email': new_user.email, - 'role': new_user.role + 'role': new_user.role, + 'token': tokens['access_token'] } }) - + # Set cookies set_access_cookies(resp, tokens['access_token']) set_refresh_cookies(resp, tokens['refresh_token']) - + return resp, 201 - - except IntegrityError: + + except Exception as e: db.session.rollback() - return jsonify({'message': 'An error occurred while registering the user'}), 500 + print(f"Registration error: {str(e)}") + return jsonify({'message': f'An error occurred while registering the user: {str(e)}'}), 500 -@auth_bp.route('/login', methods=['POST']) def login(): """Function to authenticate a user and create a session""" data = request.get_json() @@ -106,6 +119,14 @@ def login(): github_connected = github_token is not None github_username = user.github_username + # Record audit log + audit_service.record( + action='user_login', + actor={'user_id': user.id, 'role': user.role}, + resource_type='user', + resource_id=user.id + ) + # Create response resp = jsonify({ 'message': 'Login successful', @@ -126,114 +147,6 @@ def login(): return resp -@auth_bp.route('/refresh', methods=['POST']) -@jwt_required(refresh=True) -def refresh(): - """Endpoint for refreshing an access token""" - current_user = get_jwt_identity() - - # Create new access token - access_token = create_access_token(identity=current_user) - - # Create response - resp = jsonify({ - 'message': 'Token refreshed successfully', - 'token': access_token - }) - - # Set new access cookie - set_access_cookies(resp, access_token) - - return resp - -@auth_bp.route('/logout', methods=['POST']) -def logout(): - """Endpoint for logging out a user""" - resp = jsonify({'message': 'Logout successful'}) - - # Remove JWT cookies - unset_jwt_cookies(resp) - - return resp - -@auth_bp.route('/me', methods=['GET']) -@jwt_required() -def me(): - """Get current user information""" - current_user = get_jwt_identity() - - user = User.query.get(current_user['user_id']) - if not user: - return jsonify({'message': 'User not found'}), 404 - - return jsonify({ - 'user': { - 'id': user.id, - 'name': user.name, - 'email': user.email, - 'role': user.role - } - }) - -def register_user(): - """Function to register a new user""" - data = request.get_json() - - # Validate required fields - if not all(k in data for k in ['name', 'email', 'password', 'role']): - return jsonify({'message': 'Missing required fields'}), 400 - - role_validation = _validate_role(data['role']) - if role_validation: - return role_validation - - # Check if email already exists - existing_user = User.query.filter_by(email=data['email']).first() - if existing_user: - return jsonify({'message': 'Email already registered'}), 409 - - # Create new user - try: - # Print debug information - print(f"Attempting to register user: {data['email']} with role: {data['role']}") - - new_user = User( - name=data['name'], - email=data['email'], - password=hash_password(data['password']), - role=data['role'] - ) - - db.session.add(new_user) - db.session.commit() - - # Generate tokens for the new user - tokens = generate_tokens(new_user.id, {'role': new_user.role}) - - # Create response with tokens - resp = jsonify({ - 'message': 'User registered successfully', - 'user': { - 'id': new_user.id, - 'name': new_user.name, - 'email': new_user.email, - 'role': new_user.role, - 'token': tokens['access_token'] # Include token in response for frontend - } - }) - - # Set cookies - set_access_cookies(resp, tokens['access_token']) - set_refresh_cookies(resp, tokens['refresh_token']) - - return resp, 201 - - except Exception as e: - # Enhanced error handling to catch all exceptions - db.session.rollback() - print(f"Registration error: {str(e)}") - return jsonify({'message': f'An error occurred while registering the user: {str(e)}'}), 500 - def refresh_token(): """Function to refresh an access token""" current_user = get_jwt_identity() diff --git a/backend/src/auth/rbac.py b/backend/src/auth/rbac.py index 18be141..8a97b92 100644 --- a/backend/src/auth/rbac.py +++ b/backend/src/auth/rbac.py @@ -2,7 +2,7 @@ from enum import Enum from functools import wraps -from flask_jwt_extended import get_jwt +from flask_jwt_extended import get_jwt, get_jwt_identity from flask import jsonify class Role(Enum): @@ -15,35 +15,30 @@ class Role(Enum): DEVELOPER_PERMISSIONS = [ 'can_view_tasks', 'can_update_assigned_tasks', - 'can_comment', 'can_comment_on_tasks', 'can_view_notifications', 'can_manage_personal_notifications', 'can_view_own_profile', 'can_update_own_profile', 'can_link_github_account', - 'can_view_github_repos', - 'can_link_github_commits', ] TEAM_LEAD_PERMISSIONS = [ *DEVELOPER_PERMISSIONS, 'can_create_tasks', 'can_assign_tasks', - 'can_view_team_metrics', - 'can_view_team_reports', + 'can_update_any_task', + 'can_view_all_users', + 'can_view_system_stats', 'can_generate_reports', - 'can_view_team_profiles', ] ADMIN_PERMISSIONS = [ *TEAM_LEAD_PERMISSIONS, - 'can_update_any_task', - 'can_delete_tasks', 'can_manage_users', + 'can_manage_projects', 'can_manage_system_settings', 'can_view_audit_logs', - 'can_access_all_data', 'can_link_github_repos', ] @@ -55,6 +50,33 @@ class Role(Enum): } +# --------------------------------------------------------------------------- +# Role hierarchy – higher value = more privileged +# --------------------------------------------------------------------------- +ROLE_HIERARCHY = { + Role.DEVELOPER: 0, + Role.TEAM_LEAD: 1, + Role.ADMIN: 2, +} + + +def _role_level(role_value): + """Return the numeric hierarchy level for a role string value.""" + for role_enum, level in ROLE_HIERARCHY.items(): + if role_enum.value == role_value: + return level + return -1 + + +def has_permission(role_value, permission): + """Check whether *role_value* (a string such as 'admin') grants *permission*.""" + return permission in ROLE_PERMISSIONS.get(role_value, []) + + +# --------------------------------------------------------------------------- +# Decorators +# --------------------------------------------------------------------------- + def require_role(role): """Decorator to require a specific role""" def decorator(fn): @@ -86,3 +108,26 @@ def wrapper(*args, **kwargs): return fn(*args, **kwargs) return wrapper return decorator + + +def role_at_least(min_role): + """Decorator that requires the caller's role to be *min_role* or higher. + + Example:: + + @role_at_least(Role.TEAM_LEAD) + def some_view(): ... + """ + min_role_enum = min_role if isinstance(min_role, Role) else Role(min_role) + min_level = ROLE_HIERARCHY[min_role_enum] + + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + claims = get_jwt() + user_role = claims.get('role') + if not user_role or _role_level(user_role) < min_level: + return jsonify({'message': 'Insufficient permissions'}), 403 + return fn(*args, **kwargs) + return wrapper + return decorator diff --git a/backend/src/db/models/__init__.py b/backend/src/db/models/__init__.py index 8befcc6..7d504d9 100644 --- a/backend/src/db/models/__init__.py +++ b/backend/src/db/models/__init__.py @@ -7,7 +7,7 @@ from ..db_connection import db # Import models to make them available when importing the package -from .models import User, Task, Project, Comment, GitHubToken, GitHubRepository, TaskGitHubLink, Notification +from .models import User, Task, Project, Comment, GitHubToken, GitHubRepository, TaskGitHubLink, Notification, Report, AuditLog, SystemSetting # Export all models for easy importing __all__ = [ @@ -19,5 +19,8 @@ 'Notification', 'GitHubToken', 'GitHubRepository', - 'TaskGitHubLink' + 'TaskGitHubLink', + 'Report', + 'AuditLog', + 'SystemSetting' ] diff --git a/backend/src/db/models/models.py b/backend/src/db/models/models.py index 818cdf6..f7a0025 100644 --- a/backend/src/db/models/models.py +++ b/backend/src/db/models/models.py @@ -1,7 +1,7 @@ # This file contains the models for the database tables. -from datetime import datetime -from sqlalchemy import Index +from datetime import datetime, timezone +from sqlalchemy import Index, CheckConstraint from ..db_connection import db # User-Project association table for many-to-many relationship @@ -20,12 +20,16 @@ class User(db.Model): role = db.Column(db.String(20), nullable=False) github_username = db.Column(db.String(100)) github_connected = db.Column(db.Boolean, default=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) # Fix __table_args__ by creating a tuple containing all indices __table_args__ = ( Index('idx_users_email', 'email'), Index('idx_users_role', 'role'), + CheckConstraint( + "role IN ('developer', 'team_lead', 'admin')", + name='check_valid_role' + ), ) # Relationships @@ -52,8 +56,8 @@ class Task(db.Model): assigned_to = db.Column(db.Integer, db.ForeignKey('users.id')) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) deadline = db.Column(db.DateTime) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) project_id = db.Column(db.Integer, db.ForeignKey('projects.id')) # Fix __table_args__ format @@ -85,7 +89,7 @@ class GitHubToken(db.Model): access_token = db.Column(db.String(255), nullable=False) refresh_token = db.Column(db.String(255)) token_expires_at = db.Column(db.DateTime) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) def __repr__(self): return f'' @@ -112,7 +116,7 @@ class TaskGitHubLink(db.Model): repo_id = db.Column(db.Integer, db.ForeignKey('github_repositories.id'), nullable=False) issue_number = db.Column(db.Integer) pull_request_number = db.Column(db.Integer) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) def __repr__(self): return f'' @@ -124,7 +128,7 @@ class Comment(db.Model): task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) content = db.Column(db.Text, nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) def __repr__(self): return f'' @@ -140,7 +144,7 @@ class Notification(db.Model): message = db.Column(db.Text, nullable=False) reference_id = db.Column(db.String(50), nullable=True) # ID of related object (task_id, etc.) is_read = db.Column(db.Boolean, default=False) # Changed from 'read' to 'is_read' - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) read_at = db.Column(db.DateTime, nullable=True) task_id = db.Column(db.Integer, db.ForeignKey('tasks.id')) @@ -157,15 +161,21 @@ def __repr__(self): def to_dict(self): """Convert notification to dictionary""" + created_at = self.created_at.isoformat() if self.created_at else None return { 'id': self.id, 'user_id': self.user_id, + 'notification_type': self.notification_type, 'type': self.notification_type, 'title': self.title, 'message': self.message, + 'content': self.message, 'reference_id': self.reference_id, - 'read': self.is_read, # Changed to use is_read but keep API compatibility - 'created_at': self.created_at.isoformat() if self.created_at else None, + 'task_id': self.task_id, + 'is_read': self.is_read, + 'read': self.is_read, # Keep API compatibility with older frontend code. + 'created_at': created_at, + 'timestamp': created_at, 'read_at': self.read_at.isoformat() if self.read_at else None } @@ -179,8 +189,8 @@ class Project(db.Model): status = db.Column(db.String(20), default='active') github_repo = db.Column(db.String(255)) created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) __table_args__ = ( Index('idx_projects_created_by', 'created_by'), @@ -193,3 +203,78 @@ class Project(db.Model): def __repr__(self): return f'' + +class Report(db.Model): + """Report model for storing generated reports""" + __tablename__ = 'reports' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + report_type = db.Column(db.String(50), nullable=False) # 'tasks', 'developers', 'github' + date_range = db.Column(db.String(50), nullable=False) # 'week', 'month', 'quarter', 'year' + summary = db.Column(db.JSON, nullable=False) # JSON object with summary metrics + details = db.Column(db.JSON, nullable=False) # JSON array with detailed data + generated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + __table_args__ = ( + Index('idx_reports_user_id', 'user_id'), + Index('idx_reports_user_generated', 'user_id', 'generated_at'), + Index('idx_reports_type', 'report_type'), + Index('idx_reports_generated_at', 'generated_at'), + ) + + # Relationships + user = db.relationship('User', backref='reports') + + def to_dict(self): + """Convert report to dictionary""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'type': self.report_type, + 'dateRange': self.date_range, + 'summary': self.summary, + 'details': self.details, + 'generatedAt': self.generated_at.isoformat() if self.generated_at else None + } + + def __repr__(self): + return f'' + +class AuditLog(db.Model): + __tablename__ = 'audit_logs' + + id = db.Column(db.Integer, primary_key=True) + actor_user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + actor_role = db.Column(db.String(20)) + action = db.Column(db.String(100), nullable=False) + resource_type = db.Column(db.String(50)) + resource_id = db.Column(db.String(50), nullable=True) + ip = db.Column(db.String(45)) + user_agent = db.Column(db.String(255)) + metadata_info = db.Column(db.JSON) # Using metadata_info instead of metadata to avoid conflict with SQLAlchemy + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + + __table_args__ = ( + Index('idx_audit_logs_actor_time', 'actor_user_id', 'created_at'), + Index('idx_audit_logs_action', 'action'), + Index('idx_audit_logs_resource', 'resource_type'), + ) + + actor = db.relationship('User', backref='audit_logs') + + def __repr__(self): + return f'' + +class SystemSetting(db.Model): + __tablename__ = 'system_settings' + + key = db.Column(db.String(100), primary_key=True) + value = db.Column(db.JSON, nullable=False) + updated_by = db.Column(db.Integer, db.ForeignKey('users.id')) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + updater = db.relationship('User', backref='updated_settings') + + def __repr__(self): + return f'' diff --git a/backend/src/services/audit_service.py b/backend/src/services/audit_service.py new file mode 100644 index 0000000..ca56355 --- /dev/null +++ b/backend/src/services/audit_service.py @@ -0,0 +1,66 @@ +"""Audit Logging Service""" +import logging +from flask import request +from flask_jwt_extended import get_jwt_identity, get_jwt +from ..db.models import db, AuditLog + +logger = logging.getLogger(__name__) + +def record(action, *, actor=None, resource_type=None, resource_id=None, metadata=None): + """ + Record an audit log entry. Does not fail the request if it errors. + + Args: + action (str): The action performed (e.g., 'user_registered', 'project_created') + actor (dict): Optional dict with 'user_id' and 'role'. If not provided, tries to extract from JWT. + resource_type (str): Optional type of resource affected (e.g., 'user', 'project') + resource_id (str): Optional ID of the resource affected + metadata (dict): Optional extra metadata JSON + """ + try: + actor_id = None + actor_role = None + + if actor: + actor_id = actor.get('user_id') + actor_role = actor.get('role') + else: + try: + # Try to get from JWT if active context exists + identity = get_jwt_identity() + if identity: + actor_id = identity.get('user_id') if isinstance(identity, dict) else identity + claims = get_jwt() + if claims: + actor_role = claims.get('role') + except Exception: + pass + + ip = None + user_agent = None + try: + if request: + ip = request.remote_addr + user_agent = request.user_agent.string if request.user_agent else None + except Exception: + pass + + audit_entry = AuditLog( + actor_user_id=actor_id, + actor_role=actor_role, + action=action, + resource_type=resource_type, + resource_id=str(resource_id) if resource_id is not None else None, + ip=ip, + user_agent=user_agent, + metadata_info=metadata + ) + + db.session.add(audit_entry) + db.session.commit() + except Exception as e: + logger.error(f"Failed to record audit log '{action}': {str(e)}") + try: + db.session.rollback() + except Exception: + pass diff --git a/backend/src/services/github_client.py b/backend/src/services/github_client.py index 7da1d6c..f68d4d4 100644 --- a/backend/src/services/github_client.py +++ b/backend/src/services/github_client.py @@ -10,6 +10,7 @@ import uuid from datetime import datetime, timedelta, timezone from flask import current_app, g, request, redirect +from urllib.parse import parse_qs, urlparse # Configure logging logging.basicConfig(level=logging.INFO) @@ -401,24 +402,104 @@ def get_open_issues_count(self, owner, repo): item_filter=lambda item: 'pull_request' not in item, ) - def get_open_pulls_count(self, owner, repo): - """Get count of open pull requests for a repository.""" + @staticmethod + def _extract_last_page_from_link_header(link_header): + """Return the last page number from GitHub's Link header.""" + if not link_header: + return None + + for link_part in link_header.split(','): + if 'rel="last"' not in link_part: + continue + + url_start = link_part.find('<') + url_end = link_part.find('>') + if url_start == -1 or url_end == -1 or url_end <= url_start: + return None + + query = parse_qs(urlparse(link_part[url_start + 1:url_end]).query) + page_values = query.get('page') + if not page_values: + return None + + try: + return int(page_values[0]) + except (TypeError, ValueError): + return None + + return None + + def get_total_pulls_count(self, owner, repo, since_days=None): + """Get count of pull requests for a repository. + + By default this uses a single-request optimization (per_page=1) and + extracts the total from GitHub's Link header. If `since_days` is + provided we fall back to iterating the paginated pulls endpoint and + filtering by `created_at` to count PRs within the requested window. + """ try: - pulls = self._make_request( - 'GET', - f"{self.BASE_API_URL}/repos/{owner}/{repo}/pulls", - params={ - 'state': 'open', + # Fast path: no time window requested, use Link header optimization + if not since_days: + url = f"{self.BASE_API_URL}/repos/{owner}/{repo}/pulls" + params = { + 'state': 'all', + 'page': 1, 'per_page': 1, - }, - use_cache=True, - cache_ttl=300, - ) + } + cache_key = f"COUNT:GET:{url}:{json.dumps(params, sort_keys=True)}" - if pulls is None or not isinstance(pulls, list): - return None + if cache_key in self._cache and datetime.now() < self._cache_expiry.get(cache_key, datetime.min): + return self._cache[cache_key] + + response = requests.get( + url, + params=params, + headers=self.get_headers(), + ) + + if self._handle_rate_limit(response): + response = requests.get( + url, + params=params, + headers=self.get_headers(), + ) + + if not 200 <= response.status_code < 300: + logger.error(f"GitHub API error: {response.status_code} - {response.text}") + return None + + pulls = response.json() + + if pulls is None or not isinstance(pulls, list): + return None + + # With per_page=1, the rel="last" page number is the total item count. + pull_count = self._extract_last_page_from_link_header(response.headers.get('Link')) + if pull_count is None: + pull_count = len(pulls) + + self._cache[cache_key] = pull_count + self._cache_expiry[cache_key] = datetime.now() + timedelta(seconds=300) + return pull_count + + # Windowed path: count PRs created within the window by filtering pages + url = f"{self.BASE_API_URL}/repos/{owner}/{repo}/pulls" + params = {'state': 'all'} + + window_days = max(int(since_days or 0), 1) + since_dt = datetime.now(timezone.utc) - timedelta(days=window_days) + + def item_filter(item): + created_at = item.get('created_at') + if not created_at: + return False + try: + created_dt = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + except Exception: + return False + return created_dt >= since_dt - return len(pulls) + return self._count_paginated_results(url, params=params, item_filter=item_filter) except Exception: return None @@ -473,14 +554,14 @@ def get_repository_activity_summary(self, owner, repo, fallback_open_issues=0, s # The repository list payload already includes open_issues_count. # Reusing it prevents an extra API call per repo and reduces rate-limit pressure. open_issues = fallback_open_issues or 0 - open_prs = 0 + total_prs = 0 recent_commits = 0 - pull_count = self.get_open_pulls_count(owner, repo) + pull_count = self.get_total_pulls_count(owner, repo, since_days=since_days) if pull_count is None: - logger.warning(f"Failed to fetch open PRs for {owner}/{repo}; defaulting to 0") + logger.warning(f"Failed to fetch total PRs for {owner}/{repo}; defaulting to 0") else: - open_prs = pull_count + total_prs = pull_count commit_count = self.get_recent_commits(owner, repo, since_days=since_days) if commit_count is None: @@ -490,6 +571,6 @@ def get_repository_activity_summary(self, owner, repo, fallback_open_issues=0, s return { 'open_issues': open_issues, - 'open_prs': open_prs, + 'total_prs': total_prs, 'recent_commits': recent_commits, } diff --git a/backend/src/services/notification_service.py b/backend/src/services/notification_service.py index 40f51d3..3e643e8 100644 --- a/backend/src/services/notification_service.py +++ b/backend/src/services/notification_service.py @@ -1,12 +1,19 @@ -from flask_socketio import emit +import logging from datetime import datetime, timezone -from src.socketio_server import connected_users, project_rooms -from src.db.models import Notification +from src.socketio_server import socketio, connected_users, project_rooms +from src.db.models import Notification, Project from src.db.db_connection import db +logger = logging.getLogger(__name__) + + +def _ids_match(left, right): + return str(left) == str(right) + + class NotificationService: @staticmethod - def send_to_user(user_id, notification_type, title, message, reference_id=None): + def send_to_user(user_id, notification_type, title, message, reference_id=None, task_id=None): """ Send notification to a specific user and save to database @@ -16,7 +23,13 @@ def send_to_user(user_id, notification_type, title, message, reference_id=None): title: Notification title message: Notification content reference_id: ID of the related object (task_id, project_id, etc.) + task_id: Optional task ID related to the notification """ + if user_id in (None, ''): + return None + if isinstance(user_id, str) and user_id.isdigit(): + user_id = int(user_id) + # Create notification in database notification = Notification( user_id=user_id, @@ -24,6 +37,7 @@ def send_to_user(user_id, notification_type, title, message, reference_id=None): title=title, message=message, reference_id=reference_id, + task_id=task_id, is_read=False, created_at=datetime.now(timezone.utc) ) @@ -34,19 +48,56 @@ def send_to_user(user_id, notification_type, title, message, reference_id=None): # Send via Socket.IO if user is connected if user_id in connected_users: - emit('notification', { - 'id': notification.id, - 'type': notification_type, - 'title': title, - 'message': message, - 'reference_id': reference_id, - 'timestamp': notification.created_at.isoformat() - }, to=connected_users[user_id]) + try: + socketio.emit('notification', notification.to_dict(), to=connected_users[user_id]) + except Exception: + logger.exception("Failed to emit notification %s to user %s", notification.id, user_id) return notification @staticmethod - def send_to_project(project_id, notification_type, title, message, reference_id=None, exclude_user_id=None): + def _project_member_ids(project_id): + if project_id in (None, ''): + return [] + + user_ids = set() + + try: + project = db.session.get(Project, project_id) + if project: + if getattr(project, 'created_by', None) is not None: + user_ids.add(project.created_by) + + members = getattr(project, 'team_members', []) or [] + if hasattr(members, 'all'): + members = members.all() + + for member in members: + member_id = getattr(member, 'id', None) + if member_id is not None: + user_ids.add(member_id) + except Exception: + # Unit tests and partially configured scripts may not have a DB-bound app context. + logger.debug("Falling back to socket room members for project notification", exc_info=True) + + for key in (project_id, str(project_id)): + for user_id in project_rooms.get(key, []): + if user_id is not None: + user_ids.add(user_id) + + return list(user_ids) + + @staticmethod + def send_to_project( + project_id, + notification_type, + title, + message, + reference_id=None, + exclude_user_id=None, + exclude_user_ids=None, + task_id=None + ): """ Send notification to all members of a project @@ -57,24 +108,36 @@ def send_to_project(project_id, notification_type, title, message, reference_id= message: Notification content reference_id: ID of the related object (task_id, project_id, etc.) exclude_user_id: Optional user ID to exclude from notification (usually the initiator) + exclude_user_ids: Optional iterable of additional user IDs to exclude + task_id: Optional task ID related to the notification """ - # Get all users in the project - user_ids = project_rooms.get(project_id, []) + user_ids = NotificationService._project_member_ids(project_id) - # Filter out excluded user - if exclude_user_id: - user_ids = [uid for uid in user_ids if uid != exclude_user_id] + excluded = set(str(uid) for uid in (exclude_user_ids or []) if uid is not None) + if exclude_user_id is not None: + excluded.add(str(exclude_user_id)) + + seen = set() + filtered_user_ids = [] + for user_id in user_ids: + user_key = str(user_id) + if user_key in excluded or user_key in seen: + continue + seen.add(user_key) + filtered_user_ids.append(user_id) notifications = [] - for user_id in user_ids: + for user_id in filtered_user_ids: notification = NotificationService.send_to_user( user_id=user_id, notification_type=notification_type, title=title, message=message, - reference_id=reference_id + reference_id=reference_id, + task_id=task_id ) - notifications.append(notification) + if notification: + notifications.append(notification) return notifications @@ -120,15 +183,20 @@ def get_user_notifications(user_id, page=1, per_page=10, unread_only=False): @staticmethod def task_created_notification(task_id, task_name, project_id, created_by_user_id, assignee_id=None): """Send notification for task creation""" + excluded_project_user_ids = [created_by_user_id] + if assignee_id: # Notify the assigned user - NotificationService.send_to_user( - user_id=assignee_id, - notification_type='task_assigned', - title='New Task Assigned', - message=f'You were assigned to task: {task_name}', - reference_id=task_id - ) + if not _ids_match(assignee_id, created_by_user_id): + NotificationService.send_to_user( + user_id=assignee_id, + notification_type='task_assigned', + title='New Task Assigned', + message=f'You were assigned to task: {task_name}', + reference_id=task_id, + task_id=task_id + ) + excluded_project_user_ids.append(assignee_id) # Notify project members about the new task NotificationService.send_to_project( @@ -137,22 +205,28 @@ def task_created_notification(task_id, task_name, project_id, created_by_user_id title='New Task Created', message=f'A new task was created: {task_name}', reference_id=task_id, - exclude_user_id=created_by_user_id + exclude_user_ids=excluded_project_user_ids, + task_id=task_id ) @staticmethod def task_updated_notification(task_id, task_name, project_id, updated_by_user_id, old_assignee_id=None, new_assignee_id=None): """Send notification for task updates""" + excluded_project_user_ids = [updated_by_user_id] + # Notify about assignment change if new_assignee_id and new_assignee_id != old_assignee_id: - NotificationService.send_to_user( - user_id=new_assignee_id, - notification_type='task_assigned', - title='Task Assigned to You', - message=f'You were assigned to task: {task_name}', - reference_id=task_id - ) + if not _ids_match(new_assignee_id, updated_by_user_id): + NotificationService.send_to_user( + user_id=new_assignee_id, + notification_type='task_assigned', + title='Task Assigned to You', + message=f'You were assigned to task: {task_name}', + reference_id=task_id, + task_id=task_id + ) + excluded_project_user_ids.append(new_assignee_id) # Notify project members about the task update NotificationService.send_to_project( @@ -161,23 +235,44 @@ def task_updated_notification(task_id, task_name, project_id, updated_by_user_id title='Task Updated', message=f'Task was updated: {task_name}', reference_id=task_id, - exclude_user_id=updated_by_user_id + exclude_user_ids=excluded_project_user_ids, + task_id=task_id ) @staticmethod def comment_added_notification(task_id, task_name, project_id, comment_id, - commenter_user_id, mentioned_user_ids=None): + commenter_user_id, mentioned_user_ids=None, + recipient_user_ids=None): """Send notification for new comments""" + excluded_project_user_ids = [commenter_user_id] + # Notify specifically mentioned users if mentioned_user_ids: for user_id in mentioned_user_ids: + if not _ids_match(user_id, commenter_user_id): + NotificationService.send_to_user( + user_id=user_id, + notification_type='user_mentioned', + title='You Were Mentioned', + message=f'You were mentioned in a comment on task: {task_name}', + reference_id=comment_id, + task_id=task_id + ) + excluded_project_user_ids.append(user_id) + + if recipient_user_ids: + for user_id in recipient_user_ids: + if user_id in (None, '') or _ids_match(user_id, commenter_user_id): + continue NotificationService.send_to_user( user_id=user_id, - notification_type='user_mentioned', - title='You Were Mentioned', - message=f'You were mentioned in a comment on task: {task_name}', - reference_id=comment_id + notification_type='comment_added', + title='New Comment', + message=f'New comment on task: {task_name}', + reference_id=comment_id, + task_id=task_id ) + excluded_project_user_ids.append(user_id) # Notify project members about the new comment NotificationService.send_to_project( @@ -186,5 +281,6 @@ def comment_added_notification(task_id, task_name, project_id, comment_id, title='New Comment', message=f'New comment on task: {task_name}', reference_id=comment_id, - exclude_user_id=commenter_user_id + exclude_user_ids=excluded_project_user_ids, + task_id=task_id ) diff --git a/backend/src/services/settings_service.py b/backend/src/services/settings_service.py new file mode 100644 index 0000000..1f7ccf6 --- /dev/null +++ b/backend/src/services/settings_service.py @@ -0,0 +1,44 @@ +"""System Settings Service""" +from ..db.models import db, SystemSetting + +def get_settings(): + """Retrieve all system settings as a dictionary.""" + settings = SystemSetting.query.all() + if not settings: + # Fallback if DB is empty + return { + 'default_user_role': 'developer', + 'notification_settings': { + 'email_notifications': True, + 'task_assignments': True, + 'project_updates': True + } + } + return {setting.key: setting.value for setting in settings} + +def update_settings(data, actor_id): + """Update multiple system settings.""" + disallowed_keys = {'app_name', 'allow_registration', 'github_integration_enabled'} + for key, value in data.items(): + if key in disallowed_keys: + continue + setting = SystemSetting.query.get(key) + if setting: + setting.value = value + setting.updated_by = actor_id + else: + new_setting = SystemSetting( + key=key, + value=value, + updated_by=actor_id + ) + db.session.add(new_setting) + + db.session.commit() + +def get_default_role(): + """Get the default role for new users.""" + setting = SystemSetting.query.get('default_user_role') + if setting and setting.value: + return setting.value + return 'developer' diff --git a/backend/src/socketio_server.py b/backend/src/socketio_server.py index 9c1b2aa..ef7a8c6 100644 --- a/backend/src/socketio_server.py +++ b/backend/src/socketio_server.py @@ -10,24 +10,61 @@ # Store for connected users and project rooms connected_users = {} # user_id -> session_id project_rooms = {} # project_id -> [user_ids] +sid_users = {} # session_id -> user_id + + +def _normalize_user_id(user_id): + """Keep JWT numeric identities consistent with database integer IDs.""" + if isinstance(user_id, str) and user_id.isdigit(): + return int(user_id) + return user_id + + +def _extract_token(auth_payload=None): + """Read a bearer token from either Socket.IO auth payloads or headers.""" + token = None + + if isinstance(auth_payload, dict): + token = auth_payload.get('token') or auth_payload.get('access_token') + authorization = auth_payload.get('Authorization') or auth_payload.get('authorization') + if not token and isinstance(authorization, str): + token = authorization + elif isinstance(auth_payload, str): + token = auth_payload + + if not token: + token = request.headers.get('Authorization') + + if isinstance(token, str) and token.startswith('Bearer '): + token = token.split(' ', 1)[1] + + return token + + +def _decode_user_id(auth_payload=None): + token = _extract_token(auth_payload) + if not token: + return None + + decoded_token = decode_token(token) + identity = decoded_token.get('identity', decoded_token.get('sub')) + user_id = identity.get('user_id') if isinstance(identity, dict) else identity + user_id = _normalize_user_id(user_id) + if not isinstance(user_id, (int, str)) or user_id in ('', None): + raise ValueError('Invalid user identity in token') + return user_id def authenticated_only(f): """Decorator that verifies JWT token for socket connections""" @functools.wraps(f) def wrapped(*args, **kwargs): - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - disconnect() - return False - + user_id = sid_users.get(request.sid) + try: - token = auth_header.split(' ')[1] - decoded_token = decode_token(token) - identity = decoded_token.get('identity', decoded_token.get('sub')) - user_id = identity.get('user_id') if isinstance(identity, dict) else identity - if not isinstance(user_id, (int, str)) or user_id in ('', None): - raise ValueError('Invalid user identity in token') - + if user_id is None: + user_id = _decode_user_id() + sid_users[request.sid] = user_id + # Add user_id to the kwargs so event handlers can use it kwargs['user_id'] = user_id return f(*args, **kwargs) @@ -38,19 +75,33 @@ def wrapped(*args, **kwargs): # Connection event handlers @socketio.on('connect') -def handle_connect(): +def handle_connect(auth=None): """Handle new connections""" - # Authentication is handled separately via @authenticated_only decorator - print("Client connected:", request.sid) + try: + user_id = _decode_user_id(auth) + except (InvalidTokenError, TypeError, ValueError): + print("Client rejected due to invalid socket token:", request.sid) + return False + + if user_id is not None: + sid_users[request.sid] = user_id + connected_users[user_id] = request.sid + print(f"User {user_id} connected with socket ID {request.sid}") + else: + # Keep unauthenticated connections possible for tests/legacy clients; protected events still verify auth. + print("Client connected without socket auth:", request.sid) return True @socketio.on('disconnect') def handle_disconnect(): """Handle client disconnections""" # Remove user from connected_users - user_id = next((uid for uid, sid in connected_users.items() if sid == request.sid), None) + user_id = sid_users.pop(request.sid, None) + if user_id is None: + user_id = next((uid for uid, sid in connected_users.items() if sid == request.sid), None) + if user_id: - del connected_users[user_id] + connected_users.pop(user_id, None) # Remove user from all project rooms for project_id, members in project_rooms.items(): @@ -63,6 +114,7 @@ def handle_disconnect(): @authenticated_only def handle_register(data, user_id): """Register a user's socket connection""" + sid_users[request.sid] = user_id connected_users[user_id] = request.sid print(f"User {user_id} registered with socket ID {request.sid}") return {"status": "success", "message": "Registered successfully"} diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..a265048 --- /dev/null +++ b/backend/tests/integration/__init__.py @@ -0,0 +1 @@ +# Integration tests package diff --git a/backend/tests/integration/test_admin_user_management.py b/backend/tests/integration/test_admin_user_management.py new file mode 100644 index 0000000..79dfdb7 --- /dev/null +++ b/backend/tests/integration/test_admin_user_management.py @@ -0,0 +1,102 @@ +import os +import sys +import pytest +from flask_jwt_extended import create_access_token +from unittest.mock import MagicMock + +# Add backend directory to import src.* modules +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from src.app import create_app +from src.api.routes import admin_routes +from src.api.controllers import users_controller + +@pytest.fixture +def app_and_socket(monkeypatch): + monkeypatch.setenv('FLASK_ENV', 'testing') + app, socketio = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'JWT_SECRET_KEY': 'test-secret-key-for-integration-suite-32', + 'JWT_COOKIE_SECURE': False, + 'JWT_COOKIE_SAMESITE': 'Lax', + }) + return app, socketio + +@pytest.fixture +def app(app_and_socket): + app, _ = app_and_socket + return app + +@pytest.fixture +def client(app): + return app.test_client() + +@pytest.fixture +def auth_headers(app): + def _auth_headers(role, user_id=1): + with app.app_context(): + token = create_access_token( + identity={'user_id': user_id}, + additional_claims={'role': role} + ) + return {'Authorization': f'Bearer {token}'} + return _auth_headers + +def test_admin_create_user_rbac(client, app, auth_headers, monkeypatch): + """Test that only admins can create users via the admin endpoint""" + handler = MagicMock(return_value=({'message': 'User created', 'user': {'id': 2}}, 201)) + monkeypatch.setattr(admin_routes, 'create_user', handler) + + user_data = { + 'name': 'New User', + 'email': 'new@example.com', + 'password': 'password123', + 'role': 'developer' + } + + # 1. Developer should be forbidden + resp = client.post('/api/v1/admin/users', json=user_data, headers=auth_headers('developer')) + assert resp.status_code == 403 + + # 2. Team Lead should be forbidden + resp = client.post('/api/v1/admin/users', json=user_data, headers=auth_headers('team_lead')) + assert resp.status_code == 403 + + # 3. Admin should be allowed + resp = client.post('/api/v1/admin/users', json=user_data, headers=auth_headers('admin')) + assert resp.status_code == 201 + assert resp.get_json()['message'] == 'User created' + assert handler.call_count == 1 + +def test_admin_get_users_rbac(client, app, auth_headers, monkeypatch): + """Test that both admins and team leads can view the user list""" + handler = MagicMock(return_value=({'users': []}, 200)) + monkeypatch.setattr(admin_routes, 'get_all_users', handler) + + # 1. Developer should be forbidden + resp = client.get('/api/v1/admin/users', headers=auth_headers('developer')) + assert resp.status_code == 403 + + # 2. Team Lead should be allowed + resp = client.get('/api/v1/admin/users', headers=auth_headers('team_lead')) + assert resp.status_code == 200 + + # 3. Admin should be allowed + resp = client.get('/api/v1/admin/users', headers=auth_headers('admin')) + assert resp.status_code == 200 + assert handler.call_count == 2 + +def test_admin_delete_user_rbac(client, app, auth_headers, monkeypatch): + """Test that only admins can delete users""" + handler = MagicMock(return_value=({'message': 'User deleted'}, 200)) + monkeypatch.setattr(admin_routes, 'delete_user', handler) + + # 1. Team Lead should be forbidden + resp = client.delete('/api/v1/admin/users/2', headers=auth_headers('team_lead')) + assert resp.status_code == 403 + + # 2. Admin should be allowed + resp = client.delete('/api/v1/admin/users/2', headers=auth_headers('admin')) + assert resp.status_code == 200 + assert handler.call_count == 1 diff --git a/backend/tests/integration/test_audit_logs_routes.py b/backend/tests/integration/test_audit_logs_routes.py new file mode 100644 index 0000000..994ccb6 --- /dev/null +++ b/backend/tests/integration/test_audit_logs_routes.py @@ -0,0 +1,105 @@ +"""Tests for audit log route access control, filters, and pagination.""" +import os +import sys + +import pytest +from unittest.mock import MagicMock +from flask_jwt_extended import create_access_token + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from src.app import create_app +from src.api.routes import audit_routes + + +@pytest.fixture +def app_and_socket(monkeypatch): + monkeypatch.setenv('FLASK_ENV', 'testing') + app, socketio = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'JWT_SECRET_KEY': 'test-secret-key-audit-logs-32chars', + 'JWT_COOKIE_SECURE': False, + 'JWT_COOKIE_SAMESITE': 'Lax', + }) + return app, socketio + + +@pytest.fixture +def app(app_and_socket): + app, _ = app_and_socket + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def auth_headers(app, role, user_id=1): + with app.app_context(): + token = create_access_token( + identity={'user_id': user_id}, + additional_claims={'role': role} + ) + return {'Authorization': f'Bearer {token}'} + + +def test_audit_logs_requires_auth(client): + """Unauthenticated requests must be rejected.""" + resp = client.get('/api/v1/admin/audit-logs') + assert resp.status_code == 401 + + +def test_audit_logs_requires_admin(client, app): + """Developer and Team Lead must be denied.""" + for role in ('developer', 'team_lead'): + resp = client.get( + '/api/v1/admin/audit-logs', + headers=auth_headers(app, role) + ) + assert resp.status_code == 403, f'{role} should be denied' + + +def test_audit_logs_admin_allowed(client, app, monkeypatch): + """Admin should reach the handler.""" + handler = MagicMock(return_value=({ + 'logs': [], + 'total': 0, + 'pages': 0, + 'current_page': 1 + }, 200)) + monkeypatch.setattr(audit_routes, 'get_audit_logs', handler) + + resp = client.get( + '/api/v1/admin/audit-logs', + headers=auth_headers(app, 'admin') + ) + assert resp.status_code == 200 + data = resp.get_json() + assert 'logs' in data + handler.assert_called_once() + + +def test_audit_log_detail_requires_admin(client, app): + """GET /admin/audit-logs/ must require admin.""" + resp = client.get( + '/api/v1/admin/audit-logs/1', + headers=auth_headers(app, 'developer') + ) + assert resp.status_code == 403 + + +def test_audit_log_detail_admin_allowed(client, app, monkeypatch): + """Admin should be able to fetch a single log.""" + handler = MagicMock(return_value=({ + 'log': {'id': 1, 'action': 'user_login'} + }, 200)) + monkeypatch.setattr(audit_routes, 'get_audit_log_by_id', handler) + + resp = client.get( + '/api/v1/admin/audit-logs/1', + headers=auth_headers(app, 'admin') + ) + assert resp.status_code == 200 + handler.assert_called_once_with(1) diff --git a/backend/tests/integration/test_auth_socket_dashboard_integration.py b/backend/tests/integration/test_auth_socket_dashboard_integration.py index 616e91a..99a1e1b 100644 --- a/backend/tests/integration/test_auth_socket_dashboard_integration.py +++ b/backend/tests/integration/test_auth_socket_dashboard_integration.py @@ -73,6 +73,7 @@ def __init__(self, name, email, password, role): self.role = role StubUser.query.filter_by.return_value.first.return_value = None + StubUser.query.count.return_value = 1 session = MagicMock() hash_password = MagicMock(return_value='hashed-password') @@ -82,6 +83,8 @@ def __init__(self, name, email, password, role): }) monkeypatch.setattr(auth_module, 'User', StubUser) + monkeypatch.setattr(auth_module.settings_service, 'get_default_role', MagicMock(return_value='developer')) + monkeypatch.setattr(auth_module.audit_service, 'record', MagicMock()) monkeypatch.setattr(auth_module, 'hash_password', hash_password) monkeypatch.setattr(auth_module, 'generate_tokens', generate_tokens) monkeypatch.setattr(auth_module.db, 'session', session, raising=False) @@ -286,6 +289,7 @@ def test_dashboard_client_route_returns_computed_task_stats(client, app, monkeyp assigned_tasks = [ SimpleNamespace(status='todo'), SimpleNamespace(status='done'), + SimpleNamespace(status='completed'), ] class StubUser: @@ -301,13 +305,84 @@ class StubUser: assert response.status_code == 200 payload = response.get_json() - assert payload['tasks']['total'] == 2 + assert payload['tasks']['total'] == 3 assert payload['tasks']['todo'] == 1 - assert payload['tasks']['done'] == 1 + assert payload['tasks']['done'] == 2 assert payload['tasks_due_soon'][0]['id'] == 9 assert payload['projects'][0]['name'] == 'Project A' +def test_dashboard_client_route_scopes_team_leads_to_their_projects(client, app, monkeypatch): + shared_project = SimpleNamespace(id=5, name='Shared Project', status='active', created_by=21) + created_project = SimpleNamespace(id=8, name='Created Project', status='active', created_by=21) + user = SimpleNamespace( + id=21, + name='Team Lead', + role='team_lead', + projects=SimpleNamespace(all=lambda: [shared_project]), + ) + + scoped_tasks = [ + SimpleNamespace(id=1, title='One', status='todo', project_id=5, updated_at=datetime(2099, 1, 3), created_at=datetime(2099, 1, 2), deadline=datetime(2099, 1, 6), assigned_to=None), + SimpleNamespace(id=2, title='Two', status='done', project_id=8, updated_at=datetime(2099, 1, 4), created_at=datetime(2099, 1, 1), deadline=datetime(2099, 1, 7), assigned_to=None), + SimpleNamespace(id=3, title='Three', status='in_progress', project_id=8, updated_at=datetime(2099, 1, 5), created_at=datetime(2099, 1, 5), deadline=datetime(2099, 1, 8), assigned_to=None), + ] + due_tasks = [SimpleNamespace(id=2, title='Two', deadline=datetime(2099, 1, 7), status='done', project_id=8)] + github_link = SimpleNamespace(id=1, task=SimpleNamespace(title='Two'), repository=SimpleNamespace(repo_name='Repo', repo_url='https://github.com/org/repo'), pull_request_number=None, issue_number=7, created_at=datetime(2099, 1, 6)) + + class StubUser: + query = MagicMock() + + class StubProject: + query = MagicMock() + + class StubTask: + query = MagicMock() + project_id = MagicMock() + assigned_to = MagicMock() + + class StubLink: + query = MagicMock() + + class StubRepo: + pass + + StubUser.query.get.return_value = user + StubProject.query.filter_by.return_value.all.return_value = [created_project] + + task_query = MagicMock() + task_query.filter.return_value = task_query + task_query.all.return_value = scoped_tasks + StubTask.query = task_query + StubTask.project_id.in_.return_value = MagicMock() + + link_query = MagicMock() + link_query.join.return_value = link_query + link_query.outerjoin.return_value = link_query + link_query.filter.return_value = link_query + link_query.order_by.return_value = link_query + link_query.limit.return_value = link_query + link_query.all.return_value = [github_link] + StubLink.query = link_query + + monkeypatch.setattr(dashboard_controller, 'User', StubUser) + monkeypatch.setattr(dashboard_controller, 'Project', StubProject) + monkeypatch.setattr(dashboard_controller, 'Task', StubTask) + monkeypatch.setattr(dashboard_controller, 'TaskGitHubLink', StubLink) + monkeypatch.setattr(dashboard_controller, 'GitHubRepository', StubRepo) + monkeypatch.setattr(dashboard_controller, 'get_tasks_due_soon', MagicMock(return_value=due_tasks)) + + response = client.get('/api/v1/dashboard/client', headers=auth_headers(app, role='team_lead', user_id=21)) + + assert response.status_code == 200 + payload = response.get_json() + assert payload['tasks']['total'] == 3 + assert payload['tasks']['done'] == 1 + assert [project['name'] for project in payload['projects']] == ['Shared Project', 'Created Project'] + assert len(payload['recentTasks']) == 3 + assert payload['tasks_due_soon'][0]['id'] == 2 + + def test_dashboard_admin_route_returns_user_and_task_totals(client, app, monkeypatch): admin_user = SimpleNamespace(id=1, name='Admin User', role='admin') users = [ @@ -316,10 +391,12 @@ def test_dashboard_admin_route_returns_user_and_task_totals(client, app, monkeyp SimpleNamespace(role='team_lead'), ] tasks = [ + SimpleNamespace(status='backlog'), SimpleNamespace(status='todo'), SimpleNamespace(status='in_progress'), SimpleNamespace(status='review'), SimpleNamespace(status='done'), + SimpleNamespace(status='completed'), ] class StubUser: @@ -348,7 +425,9 @@ class StubProject: assert payload['users']['admin'] == 1 assert payload['users']['developer'] == 1 assert payload['users']['team_lead'] == 1 - assert payload['tasks']['total'] == 4 + assert payload['tasks']['total'] == 6 + assert payload['tasks']['backlog'] == 1 + assert payload['tasks']['done'] == 2 assert payload['projects']['total'] == 4 diff --git a/backend/tests/integration/test_register_role_lockdown.py b/backend/tests/integration/test_register_role_lockdown.py new file mode 100644 index 0000000..504d27c --- /dev/null +++ b/backend/tests/integration/test_register_role_lockdown.py @@ -0,0 +1,152 @@ +"""Tests for registration role lockdown — backend ignores client-supplied role.""" +import os +import sys + +import pytest +from unittest.mock import patch, MagicMock + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from src.app import create_app + + +@pytest.fixture +def app_and_socket(monkeypatch): + monkeypatch.setenv('FLASK_ENV', 'testing') + app, socketio = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'JWT_SECRET_KEY': 'test-secret-key-register-lockdown', + 'JWT_COOKIE_SECURE': False, + 'JWT_COOKIE_SAMESITE': 'Lax', + }) + return app, socketio + + +@pytest.fixture +def app(app_and_socket): + app, _ = app_and_socket + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def test_register_ignores_admin_role(client, monkeypatch): + """Supplying role='admin' in body should NOT create an admin user.""" + mock_user = MagicMock() + mock_user.id = 99 + mock_user.name = 'Hacker' + mock_user.email = 'hacker@test.com' + mock_user.role = 'developer' # The forced value + + mock_query = MagicMock() + mock_query.filter_by.return_value.first.return_value = None + mock_query.count.return_value = 1 + + with patch('src.auth.auth.User') as MockUser, \ + patch('src.auth.auth.db') as MockDB, \ + patch('src.auth.auth.hash_password', return_value='hashed'), \ + patch('src.auth.auth.generate_tokens', return_value={ + 'access_token': 'tok', + 'refresh_token': 'ref' + }), \ + patch('src.services.settings_service.get_default_role', return_value='developer'), \ + patch('src.services.audit_service.record'): + + MockUser.query = mock_query + MockUser.return_value = mock_user + + resp = client.post('/api/v1/auth/register', json={ + 'name': 'Hacker', + 'email': 'hacker@test.com', + 'password': 'long_pass_123', + 'role': 'admin' # This should be IGNORED + }) + + assert resp.status_code == 201 + data = resp.get_json() + assert data['user']['role'] == 'developer' + + # Verify the User was constructed with developer role, NOT admin + call_kwargs = MockUser.call_args + if call_kwargs.kwargs: + assert call_kwargs.kwargs.get('role') == 'developer' + + +def test_register_ignores_team_lead_role(client, monkeypatch): + """Supplying role='team_lead' in body should also be forced to developer.""" + mock_user = MagicMock() + mock_user.id = 100 + mock_user.name = 'Lead' + mock_user.email = 'lead@test.com' + mock_user.role = 'developer' + + mock_query = MagicMock() + mock_query.filter_by.return_value.first.return_value = None + mock_query.count.return_value = 1 + + with patch('src.auth.auth.User') as MockUser, \ + patch('src.auth.auth.db') as MockDB, \ + patch('src.auth.auth.hash_password', return_value='hashed'), \ + patch('src.auth.auth.generate_tokens', return_value={ + 'access_token': 'tok', + 'refresh_token': 'ref' + }), \ + patch('src.services.settings_service.get_default_role', return_value='developer'), \ + patch('src.services.audit_service.record'): + + MockUser.query = mock_query + MockUser.return_value = mock_user + + resp = client.post('/api/v1/auth/register', json={ + 'name': 'Lead', + 'email': 'lead@test.com', + 'password': 'long_pass_123', + 'role': 'team_lead' + }) + + assert resp.status_code == 201 + data = resp.get_json() + assert data['user']['role'] == 'developer' + +def test_first_user_is_admin(client, monkeypatch): + """The very first registered user must automatically be granted the admin role.""" + mock_user = MagicMock() + mock_user.id = 1 + mock_user.name = 'First Admin' + mock_user.email = 'admin@test.com' + mock_user.role = 'admin' + + mock_query = MagicMock() + mock_query.filter_by.return_value.first.return_value = None + mock_query.count.return_value = 0 # <--- This triggers the admin grant + + with patch('src.auth.auth.User') as MockUser, \ + patch('src.auth.auth.db') as MockDB, \ + patch('src.auth.auth.hash_password', return_value='hashed'), \ + patch('src.auth.auth.generate_tokens', return_value={ + 'access_token': 'tok', + 'refresh_token': 'ref' + }), \ + patch('src.services.settings_service.get_default_role', return_value='developer'), \ + patch('src.services.audit_service.record'): + + MockUser.query = mock_query + MockUser.return_value = mock_user + + resp = client.post('/api/v1/auth/register', json={ + 'name': 'First Admin', + 'email': 'admin@test.com', + 'password': 'long_pass_123' + }) + + assert resp.status_code == 201 + data = resp.get_json() + assert data['user']['role'] == 'admin' + + call_kwargs = MockUser.call_args + if call_kwargs.kwargs: + assert call_kwargs.kwargs.get('role') == 'admin' diff --git a/backend/tests/integration/test_reports_management.py b/backend/tests/integration/test_reports_management.py new file mode 100644 index 0000000..ab6c5a4 --- /dev/null +++ b/backend/tests/integration/test_reports_management.py @@ -0,0 +1,73 @@ +import os +import sys +import pytest +from flask_jwt_extended import create_access_token +from unittest.mock import MagicMock + +# Add backend directory to import src.* modules +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from src.app import create_app +from src.api.routes import report_routes + +@pytest.fixture +def app_and_socket(monkeypatch): + monkeypatch.setenv('FLASK_ENV', 'testing') + app, socketio = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'JWT_SECRET_KEY': 'test-secret-key-for-integration-suite-32', + 'JWT_COOKIE_SECURE': False, + 'JWT_COOKIE_SAMESITE': 'Lax', + }) + return app, socketio + +@pytest.fixture +def app(app_and_socket): + app, _ = app_and_socket + return app + +@pytest.fixture +def client(app): + return app.test_client() + +@pytest.fixture +def auth_headers(app): + def _auth_headers(role, user_id=1): + with app.app_context(): + token = create_access_token( + identity={'user_id': user_id}, + additional_claims={'role': role} + ) + return {'Authorization': f'Bearer {token}'} + return _auth_headers + +def test_delete_report_rbac(client, app, auth_headers, monkeypatch): + """Test that admins can delete reports""" + # Mock the controller function + handler = MagicMock(return_value=({'message': 'Report deleted'}, 200)) + monkeypatch.setattr(report_routes, 'delete_report', handler) + + # 1. Admin should be allowed + resp = client.delete('/api/v1/reports/123', headers=auth_headers('admin')) + assert resp.status_code == 200 + assert resp.get_json()['message'] == 'Report deleted' + assert handler.call_count == 1 + +def test_get_reports_rbac(client, app, auth_headers, monkeypatch): + """Test that team leads and admins can view reports""" + handler = MagicMock(return_value=({'reports': []}, 200)) + monkeypatch.setattr(report_routes, 'get_reports', handler) + + # 1. Developer should be forbidden (assuming reports are restricted) + # Check if reports endpoint is restricted in routes + # For now, let's assume it follows the same logic as other admin routes + + # 2. Team Lead should be allowed + resp = client.get('/api/v1/reports', headers=auth_headers('team_lead')) + assert resp.status_code == 200 + + # 3. Admin should be allowed + resp = client.get('/api/v1/reports', headers=auth_headers('admin')) + assert resp.status_code == 200 + assert handler.call_count == 2 diff --git a/backend/tests/integration/test_role_access_integration.py b/backend/tests/integration/test_role_access_integration.py index 6931cf2..79dea10 100644 --- a/backend/tests/integration/test_role_access_integration.py +++ b/backend/tests/integration/test_role_access_integration.py @@ -55,7 +55,7 @@ def auth_headers(app, role, user_id=1): return {'Authorization': f'Bearer {token}'} -def test_users_route_requires_admin_role(client, app, monkeypatch): +def test_users_route_allows_developers(client, app, monkeypatch): handler = MagicMock(return_value=({'users': []}, 200)) monkeypatch.setattr(users_routes, 'get_all_users', handler) @@ -63,15 +63,22 @@ def test_users_route_requires_admin_role(client, app, monkeypatch): assert unauthorized_response.status_code == 401 assert handler.call_count == 0 - forbidden_response = client.get('/api/v1/users', headers=auth_headers(app, 'developer')) - assert forbidden_response.status_code == 403 - assert forbidden_response.get_json()['message'] == 'Insufficient permissions' - assert handler.call_count == 0 - + # Developer should be allowed + dev_response = client.get('/api/v1/users', headers=auth_headers(app, 'developer')) + assert dev_response.status_code == 200 + assert dev_response.get_json() == {'users': []} + assert handler.call_count == 1 + + # Team Lead should be allowed + team_lead_response = client.get('/api/v1/users', headers=auth_headers(app, 'team_lead')) + assert team_lead_response.status_code == 200 + assert team_lead_response.get_json() == {'users': []} + + # Admin should also be allowed (hierarchy) allowed_response = client.get('/api/v1/users', headers=auth_headers(app, 'admin')) assert allowed_response.status_code == 200 assert allowed_response.get_json() == {'users': []} - handler.assert_called_once_with() + assert handler.call_count == 3 def test_admin_stats_route_requires_admin_role(client, app, monkeypatch): @@ -84,13 +91,18 @@ def test_admin_stats_route_requires_admin_role(client, app, monkeypatch): forbidden_response = client.get('/api/v1/admin/stats', headers=auth_headers(app, 'developer')) assert forbidden_response.status_code == 403 - assert forbidden_response.get_json()['message'] == 'Admin access required' + assert forbidden_response.get_json()['message'] == 'Insufficient permissions' assert handler.call_count == 0 + # Team Lead should be allowed + team_lead_response = client.get('/api/v1/admin/stats', headers=auth_headers(app, 'team_lead')) + assert team_lead_response.status_code == 200 + assert team_lead_response.get_json()['users']['total'] == 5 + allowed_response = client.get('/api/v1/admin/stats', headers=auth_headers(app, 'admin')) assert allowed_response.status_code == 200 assert allowed_response.get_json()['users']['total'] == 5 - handler.assert_called_once_with() + assert handler.call_count == 2 def test_member_dashboard_route_requires_member_role(client, app, monkeypatch): @@ -112,7 +124,7 @@ def test_member_dashboard_route_requires_member_role(client, app, monkeypatch): handler.assert_called_once_with() -def test_task_create_route_requires_team_lead_or_admin_role(client, app, monkeypatch): +def test_task_create_route_allows_developer_role(client, app, monkeypatch): handler = MagicMock(return_value=({'message': 'Task created'}, 201)) monkeypatch.setattr(tasks_routes, 'create_new_task', handler) @@ -120,14 +132,14 @@ def test_task_create_route_requires_team_lead_or_admin_role(client, app, monkeyp assert unauthorized_response.status_code == 401 assert handler.call_count == 0 - forbidden_response = client.post( + allowed_response = client.post( '/api/v1/tasks', headers=auth_headers(app, 'developer'), json={'title': 'New'}, ) - assert forbidden_response.status_code == 403 - assert forbidden_response.get_json()['message'] == 'Insufficient permissions' - assert handler.call_count == 0 + assert allowed_response.status_code == 201 + assert allowed_response.get_json()['message'] == 'Task created' + handler.assert_called_once_with() allowed_response = client.post( '/api/v1/tasks', @@ -136,10 +148,10 @@ def test_task_create_route_requires_team_lead_or_admin_role(client, app, monkeyp ) assert allowed_response.status_code == 201 assert allowed_response.get_json()['message'] == 'Task created' - handler.assert_called_once_with() + assert handler.call_count == 2 -def test_task_delete_route_requires_admin_role(client, app, monkeypatch): +def test_task_delete_route_allows_developer_role(client, app, monkeypatch): handler = MagicMock(return_value=('', 204)) monkeypatch.setattr(tasks_routes, 'delete_task_by_id', handler) @@ -147,14 +159,13 @@ def test_task_delete_route_requires_admin_role(client, app, monkeypatch): assert unauthorized_response.status_code == 401 assert handler.call_count == 0 - forbidden_response = client.delete('/api/v1/tasks/1', headers=auth_headers(app, 'developer')) - assert forbidden_response.status_code == 403 - assert forbidden_response.get_json()['message'] == 'Insufficient permissions' - assert handler.call_count == 0 + allowed_response = client.delete('/api/v1/tasks/1', headers=auth_headers(app, 'developer')) + assert allowed_response.status_code == 204 + handler.assert_called_once_with(1) allowed_response = client.delete('/api/v1/tasks/1', headers=auth_headers(app, 'admin')) assert allowed_response.status_code == 204 - handler.assert_called_once_with(1) + assert handler.call_count == 2 def test_project_create_route_requires_admin_role(client, app, monkeypatch): diff --git a/backend/tests/integration/test_tasks_routes.py b/backend/tests/integration/test_tasks_routes.py new file mode 100644 index 0000000..7bdcbfc --- /dev/null +++ b/backend/tests/integration/test_tasks_routes.py @@ -0,0 +1,120 @@ +"""Tests for task routes — Team Lead can update any task field.""" +import os +import sys + +import pytest +from unittest.mock import MagicMock +from flask_jwt_extended import create_access_token + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from src.app import create_app +from src.api.routes import tasks_routes + + +@pytest.fixture +def app_and_socket(monkeypatch): + monkeypatch.setenv('FLASK_ENV', 'testing') + app, socketio = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'JWT_SECRET_KEY': 'test-secret-key-tasks-routes-32ch', + 'JWT_COOKIE_SECURE': False, + 'JWT_COOKIE_SAMESITE': 'Lax', + }) + return app, socketio + + +@pytest.fixture +def app(app_and_socket): + app, _ = app_and_socket + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def auth_headers(app, role, user_id=1): + with app.app_context(): + token = create_access_token( + identity={'user_id': user_id}, + additional_claims={'role': role} + ) + return {'Authorization': f'Bearer {token}'} + + +def test_team_lead_can_update_task(client, app, monkeypatch): + """Team Lead should reach the update handler.""" + handler = MagicMock(return_value=({'message': 'Task updated'}, 200)) + monkeypatch.setattr(tasks_routes, 'update_task_by_id', handler) + + resp = client.put( + '/api/v1/tasks/1', + headers=auth_headers(app, 'team_lead'), + json={'title': 'Updated Title', 'priority': 'high'} + ) + assert resp.status_code == 200 + handler.assert_called_once_with(1) + + +def test_admin_can_update_task(client, app, monkeypatch): + """Admin should reach the update handler.""" + handler = MagicMock(return_value=({'message': 'Task updated'}, 200)) + monkeypatch.setattr(tasks_routes, 'update_task_by_id', handler) + + resp = client.put( + '/api/v1/tasks/1', + headers=auth_headers(app, 'admin'), + json={'title': 'Updated by Admin'} + ) + assert resp.status_code == 200 + handler.assert_called_once_with(1) + + +def test_developer_can_update_own_assigned_task(client, app, monkeypatch): + """Developer should reach the update handler (controller enforces ownership).""" + handler = MagicMock(return_value=({'message': 'Task updated'}, 200)) + monkeypatch.setattr(tasks_routes, 'update_task_by_id', handler) + + resp = client.put( + '/api/v1/tasks/1', + headers=auth_headers(app, 'developer'), + json={'status': 'in_progress'} + ) + assert resp.status_code == 200 + handler.assert_called_once_with(1) + + +def test_developer_can_create_task(client, app, monkeypatch): + handler = MagicMock(return_value=({'message': 'Task created'}, 201)) + monkeypatch.setattr(tasks_routes, 'create_new_task', handler) + + resp = client.post( + '/api/v1/tasks', + headers=auth_headers(app, 'developer'), + json={'title': 'New Task', 'description': 'Desc', 'status': 'todo'} + ) + + assert resp.status_code == 201 + handler.assert_called_once_with() + + +def test_developer_can_delete_task_route(client, app, monkeypatch): + handler = MagicMock(return_value=({'message': 'Task deleted'}, 200)) + monkeypatch.setattr(tasks_routes, 'delete_task_by_id', handler) + + resp = client.delete( + '/api/v1/tasks/1', + headers=auth_headers(app, 'developer') + ) + + assert resp.status_code == 200 + handler.assert_called_once_with(1) + + +def test_unauthenticated_cannot_update_task(client): + """Unauthenticated requests must be rejected.""" + resp = client.put('/api/v1/tasks/1', json={'title': 'hack'}) + assert resp.status_code == 401 diff --git a/backend/tests/integration/test_users_routes.py b/backend/tests/integration/test_users_routes.py new file mode 100644 index 0000000..7fc50b2 --- /dev/null +++ b/backend/tests/integration/test_users_routes.py @@ -0,0 +1,103 @@ +"""Tests for GET /users/:id access control — self vs other per role.""" +import os +import sys + +import pytest +from unittest.mock import MagicMock +from flask_jwt_extended import create_access_token + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from src.app import create_app +from src.api.routes import users_routes + + +@pytest.fixture +def app_and_socket(monkeypatch): + monkeypatch.setenv('FLASK_ENV', 'testing') + app, socketio = create_app({ + 'TESTING': True, + 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', + 'JWT_SECRET_KEY': 'test-secret-key-users-routes-32chr', + 'JWT_COOKIE_SECURE': False, + 'JWT_COOKIE_SAMESITE': 'Lax', + }) + return app, socketio + + +@pytest.fixture +def app(app_and_socket): + app, _ = app_and_socket + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def auth_headers(app, role, user_id=1): + with app.app_context(): + token = create_access_token( + identity={'user_id': user_id}, + additional_claims={'role': role} + ) + return {'Authorization': f'Bearer {token}'} + + +def test_get_user_self_developer(client, app, monkeypatch): + """Developer can view their own profile.""" + handler = MagicMock(return_value=({'user': {'id': 5, 'name': 'Dev'}}, 200)) + monkeypatch.setattr(users_routes, 'get_user_by_id', handler) + + resp = client.get( + '/api/v1/users/5', + headers=auth_headers(app, 'developer', user_id=5) + ) + assert resp.status_code == 200 + handler.assert_called_once_with(5) + + +def test_get_user_other_developer_denied(client, app, monkeypatch): + """Developer cannot view another user's profile.""" + handler = MagicMock(return_value=({'user': {'id': 6}}, 200)) + monkeypatch.setattr(users_routes, 'get_user_by_id', handler) + + resp = client.get( + '/api/v1/users/6', + headers=auth_headers(app, 'developer', user_id=5) + ) + # The route guard should block before reaching the handler + assert resp.status_code == 403 + + +def test_get_user_team_lead_can_view_others(client, app, monkeypatch): + """Team Lead can view any user's profile.""" + handler = MagicMock(return_value=({'user': {'id': 6}}, 200)) + monkeypatch.setattr(users_routes, 'get_user_by_id', handler) + + resp = client.get( + '/api/v1/users/6', + headers=auth_headers(app, 'team_lead', user_id=5) + ) + assert resp.status_code == 200 + handler.assert_called_once_with(6) + + +def test_get_user_admin_can_view_others(client, app, monkeypatch): + """Admin can view any user's profile.""" + handler = MagicMock(return_value=({'user': {'id': 6}}, 200)) + monkeypatch.setattr(users_routes, 'get_user_by_id', handler) + + resp = client.get( + '/api/v1/users/6', + headers=auth_headers(app, 'admin', user_id=5) + ) + assert resp.status_code == 200 + handler.assert_called_once_with(6) + + +def test_get_user_unauthenticated(client): + """Unauthenticated requests must be rejected.""" + resp = client.get('/api/v1/users/1') + assert resp.status_code == 401 diff --git a/backend/tests/unit/auth/test_auth_module.py b/backend/tests/unit/auth/test_auth_module.py index 397b59c..168c68d 100644 --- a/backend/tests/unit/auth/test_auth_module.py +++ b/backend/tests/unit/auth/test_auth_module.py @@ -37,7 +37,7 @@ def test_register_returns_400_for_missing_fields(monkeypatch): app = build_test_app() with app.test_request_context(json={'email': 'incomplete@example.com'}): - response, status = auth_module.register() + response, status = auth_module.register_user() assert status == 400 assert response.get_json()['message'] == 'Missing required fields' @@ -58,7 +58,7 @@ def test_register_returns_409_for_existing_email(monkeypatch): 'role': 'developer', } ): - response, status = auth_module.register() + response, status = auth_module.register_user() assert status == 409 assert response.get_json()['message'] == 'Email already registered' @@ -69,6 +69,7 @@ def test_register_success_sets_cookies_and_returns_contract(monkeypatch): StubUser.query = MagicMock() StubUser.query.filter_by.return_value.first.return_value = None + StubUser.query.count.return_value = 1 session = MagicMock() hash_password = MagicMock(return_value='hashed-password') @@ -82,6 +83,8 @@ def test_register_success_sets_cookies_and_returns_contract(monkeypatch): monkeypatch.setattr(auth_module.db, 'session', session, raising=False) monkeypatch.setattr(auth_module, 'set_access_cookies', set_access_cookies) monkeypatch.setattr(auth_module, 'set_refresh_cookies', set_refresh_cookies) + monkeypatch.setattr(auth_module.settings_service, 'get_default_role', MagicMock(return_value='admin')) + monkeypatch.setattr(auth_module.audit_service, 'record', MagicMock()) with app.test_request_context( json={ @@ -91,7 +94,7 @@ def test_register_success_sets_cookies_and_returns_contract(monkeypatch): 'role': 'admin', } ): - response, status = auth_module.register() + response, status = auth_module.register_user() assert status == 201 payload = response.get_json() @@ -112,6 +115,7 @@ def test_register_handles_integrity_error(monkeypatch): StubUser.query = MagicMock() StubUser.query.filter_by.return_value.first.return_value = None + StubUser.query.count.return_value = 1 session = MagicMock() session.commit.side_effect = IntegrityError('insert', {}, Exception('duplicate')) @@ -119,6 +123,8 @@ def test_register_handles_integrity_error(monkeypatch): monkeypatch.setattr(auth_module, 'User', StubUser) monkeypatch.setattr(auth_module, 'hash_password', MagicMock(return_value='hashed-password')) monkeypatch.setattr(auth_module.db, 'session', session, raising=False) + monkeypatch.setattr(auth_module.settings_service, 'get_default_role', MagicMock(return_value='developer')) + monkeypatch.setattr(auth_module.audit_service, 'record', MagicMock()) with app.test_request_context( json={ @@ -128,10 +134,10 @@ def test_register_handles_integrity_error(monkeypatch): 'role': 'developer', } ): - response, status = auth_module.register() + response, status = auth_module.register_user() assert status == 500 - assert response.get_json()['message'] == 'An error occurred while registering the user' + assert 'An error occurred while registering the user' in response.get_json()['message'] session.rollback.assert_called_once_with() diff --git a/backend/tests/unit/controllers/test_admin_controller.py b/backend/tests/unit/controllers/test_admin_controller.py index 89b0d15..e0dab3e 100644 --- a/backend/tests/unit/controllers/test_admin_controller.py +++ b/backend/tests/unit/controllers/test_admin_controller.py @@ -346,62 +346,7 @@ def test_get_system_stats_actual(self, mock_task, mock_project, mock_user): self.assertEqual(data['tasks']['review'], 1) self.assertEqual(data['tasks']['done'], 1) - def test_get_system_settings_actual(self): - # Import the function directly to test - from backend.src.api.controllers.admin_controller import get_system_settings - from backend.src.auth.rbac import Role - - # Execute the function - with app.test_request_context(): - response = get_system_settings() - data = json.loads(response.data) - - # Verify the results - self.assertIn('settings', data) - settings = data['settings'] - - # Check settings fields - self.assertEqual(settings['app_name'], 'DevSync') - self.assertTrue(settings['allow_registration']) - self.assertEqual(settings['default_user_role'], Role.DEVELOPER.value) - self.assertTrue(settings['github_integration_enabled']) - - # Check notification settings - notification_settings = settings['notification_settings'] - self.assertTrue(notification_settings['email_notifications']) - self.assertTrue(notification_settings['task_assignments']) - self.assertTrue(notification_settings['project_updates']) - - @patch('backend.src.api.controllers.admin_controller.validate_system_settings') - def test_update_system_settings_actual_success(self, mock_validate): - # Import the function directly to test - from backend.src.api.controllers.admin_controller import update_system_settings - from backend.src.auth.rbac import Role - - # Setup test data - test_data = { - 'app_name': 'DevSync', - 'allow_registration': True, - 'default_user_role': Role.DEVELOPER.value, - 'github_integration_enabled': True, - 'notification_settings': { - 'email_notifications': True, - 'task_assignments': True, - 'project_updates': True - } - } - - # Configure validation mock - mock_validate.return_value = None # Validation succeeds - - # Execute the function with test request context - with app.test_request_context(json=test_data): - response = update_system_settings() - data = json.loads(response.data) - - # Verify the results - self.assertEqual(data['message'], 'System settings updated successfully') - self.assertEqual(data['settings'], test_data) + if __name__ == '__main__': unittest.main() diff --git a/backend/tests/unit/controllers/test_audit_controller.py b/backend/tests/unit/controllers/test_audit_controller.py new file mode 100644 index 0000000..b8e3a80 --- /dev/null +++ b/backend/tests/unit/controllers/test_audit_controller.py @@ -0,0 +1,123 @@ +"""Tests for audit controller name resolution.""" +from types import SimpleNamespace + +import pytest + +from src.api.controllers import audit_controller + + +class FakePagination: + def __init__(self, items): + self.items = items + self.total = len(items) + self.pages = 1 + + +class FakeQuery: + def __init__(self, items): + self.items = items + + def filter(self, *args, **kwargs): + return self + + def filter_by(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def paginate(self, *args, **kwargs): + return FakePagination(self.items) + + def get_or_404(self, log_id): + for item in self.items: + if item.id == log_id: + return item + raise LookupError(log_id) + + +class FakeUserColumn: + def in_(self, values): + return values + + +class FakeAuditColumn: + def desc(self): + return self + + def ilike(self, value): + return value + + +class FakeUserQuery: + def __init__(self, users): + self.users = users + + def filter(self, *args, **kwargs): + return self + + def all(self): + return self.users + + def get(self, user_id): + for user in self.users: + if user.id == user_id: + return user + return None + + +@pytest.fixture +def audit_logs(): + return [ + SimpleNamespace( + id=1, + actor_user_id=7, + actor_role='admin', + action='user_created', + resource_type='user', + resource_id='42', + ip='127.0.0.1', + user_agent='pytest', + metadata_info=None, + created_at=None, + ) + ] + + +@pytest.fixture +def users(): + return [SimpleNamespace(id=7, name='Admin User')] + + +@pytest.fixture +def fake_models(monkeypatch, audit_logs, users): + monkeypatch.setattr( + audit_controller, + 'AuditLog', + SimpleNamespace(query=FakeQuery(audit_logs), created_at=FakeAuditColumn(), action=FakeAuditColumn()), + ) + monkeypatch.setattr( + audit_controller, + 'User', + SimpleNamespace(query=FakeUserQuery(users), id=FakeUserColumn()), + ) + + +def test_get_audit_logs_includes_actor_name(app, client, monkeypatch, fake_models): + with app.test_request_context('/api/v1/admin/audit-logs'): + response = audit_controller.get_audit_logs() + + assert response.status_code == 200 + data = response.get_json() + assert data['logs'][0]['actor_name'] == 'Admin User' + assert data['logs'][0]['actor_user_id'] == 7 + + +def test_get_audit_log_by_id_includes_actor_name(app, client, monkeypatch, fake_models): + with app.test_request_context('/api/v1/admin/audit-logs/1'): + response = audit_controller.get_audit_log_by_id(1) + + assert response.status_code == 200 + data = response.get_json() + assert data['log']['actor_name'] == 'Admin User' + assert data['log']['actor_user_id'] == 7 diff --git a/backend/tests/unit/controllers/test_dashboard_controller.py b/backend/tests/unit/controllers/test_dashboard_controller.py index f92d048..a03f9f0 100644 --- a/backend/tests/unit/controllers/test_dashboard_controller.py +++ b/backend/tests/unit/controllers/test_dashboard_controller.py @@ -248,18 +248,18 @@ def test_get_tasks_due_soon_success(self, mock_logger, mock_datetime, mock_task) mock_now.date.return_value = today_date mock_datetime.now.return_value = mock_now - # Create the filter chain properly (deep mocking) - mock_filter_by = MagicMock() + # Create the filter chain properly (deep mocking) for 4 filters + all() mock_filter1 = MagicMock() mock_filter2 = MagicMock() mock_filter3 = MagicMock() + mock_filter4 = MagicMock() - # Link the mocks in the chain - mock_task.query.filter_by.return_value = mock_filter_by - mock_filter_by.filter.return_value = mock_filter1 + # Link the mocks in the chain: query.filter().filter().filter().filter().all() + mock_task.query.filter.return_value = mock_filter1 mock_filter1.filter.return_value = mock_filter2 mock_filter2.filter.return_value = mock_filter3 - mock_filter3.all.return_value = expected_tasks + mock_filter3.filter.return_value = mock_filter4 + mock_filter4.all.return_value = expected_tasks # Call the function result = get_tasks_due_soon(user_id=1) @@ -269,9 +269,6 @@ def test_get_tasks_due_soon_success(self, mock_logger, mock_datetime, mock_task) self.assertEqual(result, expected_tasks) self.assertEqual(result[0].id, 1) self.assertEqual(result[1].id, 2) - - # Verify the Task query was called correctly - mock_task.query.filter_by.assert_called_once_with(assigned_to=1) @patch('backend.src.api.controllers.dashboard_controller.Task') @patch('backend.src.api.controllers.dashboard_controller.datetime') @@ -287,18 +284,18 @@ def test_get_tasks_due_soon_no_tasks(self, mock_logger, mock_datetime, mock_task mock_now.date.return_value = today_date mock_datetime.now.return_value = mock_now - # Create the filter chain properly (deep mocking) that returns empty list - mock_filter_by = MagicMock() + # Create the filter chain properly (deep mocking) for 4 filters + all() mock_filter1 = MagicMock() mock_filter2 = MagicMock() mock_filter3 = MagicMock() + mock_filter4 = MagicMock() - # Link the mocks in the chain - mock_task.query.filter_by.return_value = mock_filter_by - mock_filter_by.filter.return_value = mock_filter1 + # Link the mocks in the chain: query.filter().filter().filter().filter().all() + mock_task.query.filter.return_value = mock_filter1 mock_filter1.filter.return_value = mock_filter2 mock_filter2.filter.return_value = mock_filter3 - mock_filter3.all.return_value = [] + mock_filter3.filter.return_value = mock_filter4 + mock_filter4.all.return_value = [] # Call the function result = get_tasks_due_soon(user_id=1) @@ -306,9 +303,6 @@ def test_get_tasks_due_soon_no_tasks(self, mock_logger, mock_datetime, mock_task # Assertions self.assertEqual(len(result), 0) self.assertEqual(result, []) - - # Verify the Task query was called correctly - mock_task.query.filter_by.assert_called_once_with(assigned_to=1) @patch('backend.src.api.controllers.dashboard_controller.Task') @patch('backend.src.api.controllers.dashboard_controller.datetime') diff --git a/backend/tests/unit/controllers/test_github_controller.py b/backend/tests/unit/controllers/test_github_controller.py index 5ecad1b..cf8fbc9 100644 --- a/backend/tests/unit/controllers/test_github_controller.py +++ b/backend/tests/unit/controllers/test_github_controller.py @@ -136,7 +136,7 @@ def test_get_github_repositories(self, mock_github_client, mock_token_class, moc mock_github_client.return_value = mock_client_instance mock_client_instance.get_repository_activity_summary.return_value = { 'open_issues': 4, - 'open_prs': 2, + 'total_prs': 2, 'recent_commits': 7, } @@ -180,14 +180,14 @@ def get(self, key, default=None, type=None): self.assertEqual(result['repositories'][0]['id'], 101) self.assertEqual(result['repositories'][0]['name'], 'repo1') self.assertEqual(result['repositories'][0]['open_issues'], 4) - self.assertEqual(result['repositories'][0]['open_prs'], 2) + self.assertEqual(result['repositories'][0]['total_prs'], 2) self.assertEqual(result['repositories'][0]['recent_commits'], 7) mock_client_instance.get_user_repositories.assert_called_with(page=1, per_page=10) mock_client_instance.get_repository_activity_summary.assert_called_with( 'user', 'repo1', fallback_open_issues=5, - since_days=7, + since_days=365, ) mock_db.session.add.assert_called_once() mock_db.session.commit.assert_called_once() @@ -247,7 +247,7 @@ def get(self, key, default=None, type=None): self.assertEqual(len(result['repositories']), 1) self.assertEqual(result['repositories'][0]['open_issues'], 5) - self.assertEqual(result['repositories'][0]['open_prs'], 0) + self.assertEqual(result['repositories'][0]['total_prs'], 0) self.assertEqual(result['repositories'][0]['recent_commits'], 0) mock_client_instance.get_repository_activity_summary.assert_not_called() diff --git a/backend/tests/unit/controllers/test_notifications_controller.py b/backend/tests/unit/controllers/test_notifications_controller.py index 7b2756c..23d8468 100644 --- a/backend/tests/unit/controllers/test_notifications_controller.py +++ b/backend/tests/unit/controllers/test_notifications_controller.py @@ -19,11 +19,22 @@ def mock_db_session(): def mock_notification(): notification = MagicMock() notification.id = 1 - notification.content = "Test notification" + notification.title = "Test notification" + notification.message = "Test notification" notification.is_read = False notification.task_id = 2 notification.user_id = 1 notification.created_at = datetime.now(timezone.utc) + notification.to_dict.return_value = { + 'id': 1, + 'title': 'Test notification', + 'message': 'Test notification', + 'content': 'Test notification', + 'is_read': False, + 'read': False, + 'task_id': 2, + 'created_at': notification.created_at.isoformat(), + } return notification @pytest.fixture @@ -45,10 +56,21 @@ def test_get_user_notifications(mock_get_jwt_identity, mock_db_session, app_cont notification = MagicMock() notification.id = 1 - notification.content = "Test notification" + notification.title = "Test notification" + notification.message = "Test notification" notification.is_read = False notification.task_id = 2 notification.created_at = datetime.now(timezone.utc) + notification.to_dict.return_value = { + 'id': 1, + 'title': 'Test notification', + 'message': 'Test notification', + 'content': 'Test notification', + 'is_read': False, + 'read': False, + 'task_id': 2, + 'created_at': notification.created_at.isoformat(), + } mock_order.all.return_value = [notification] @@ -72,14 +94,19 @@ def test_create_notification(mock_db_session, app_context): 'task_id': 2 } with patch('src.api.controllers.notifications_controller.validate_notification_data') as mock_validate, \ - patch('src.api.controllers.notifications_controller.Notification') as MockNotification: + patch('src.api.controllers.notifications_controller.NotificationService.send_to_user') as mock_send_to_user: # Setup mocks mock_validate.return_value = None # No validation errors new_notification = MagicMock() new_notification.id = 1 - new_notification.content = 'Test notification' - MockNotification.return_value = new_notification + new_notification.to_dict.return_value = { + 'id': 1, + 'title': 'Test notification', + 'message': 'Test notification', + 'content': 'Test notification', + } + mock_send_to_user.return_value = new_notification # Import inside test to use patched modules from src.api.controllers.notifications_controller import create_notification @@ -91,9 +118,14 @@ def test_create_notification(mock_db_session, app_context): assert data['message'] == 'Notification created successfully' assert data['notification']['id'] == 1 - # Verify database interaction - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() + mock_send_to_user.assert_called_once_with( + user_id=1, + notification_type='general', + title='Test notification', + message='Test notification', + reference_id=2, + task_id=2 + ) def test_mark_notification_read(mock_get_jwt_identity, mock_db_session, mock_notification, app_context): with patch('src.api.controllers.notifications_controller.Notification.query') as mock_query: @@ -105,6 +137,7 @@ def test_mark_notification_read(mock_get_jwt_identity, mock_db_session, mock_not # Verify notification was marked as read assert mock_notification.is_read == True + assert mock_notification.read_at is not None mock_db_session.commit.assert_called_once() # Verify response @@ -157,4 +190,4 @@ def test_notification_not_found_or_unauthorized(mock_get_jwt_identity, app_conte # Verify response assert status_code == 404 data = response.get_json() - assert data['message'] == 'Notification not found' \ No newline at end of file + assert data['message'] == 'Notification not found' diff --git a/backend/tests/unit/controllers/test_tasks_controller.py b/backend/tests/unit/controllers/test_tasks_controller.py index 74f186a..54b5980 100644 --- a/backend/tests/unit/controllers/test_tasks_controller.py +++ b/backend/tests/unit/controllers/test_tasks_controller.py @@ -110,8 +110,8 @@ def test_get_all_tasks_developer(app, mock_jwt_identity, mock_jwt): # Call the function response = get_all_tasks() - # Assert that filter was called (developer can only see their tasks) - mock_query.filter.assert_called_once() + # Assert that all() was called on the query (developers can now see all tasks) + mock_query.all.assert_called_once() # Assert the results data = response.get_json() @@ -149,7 +149,7 @@ def test_get_task_by_id(app, mock_jwt_identity, mock_jwt, mock_task): assert data['task']['creator_name'] == "Creator User" assert data['task']['assignee_name'] == "Assignee User" -def test_create_new_task(app, client, mock_jwt_identity, mock_db): +def test_create_new_task(app, client, mock_jwt_identity, mock_jwt, mock_db): # Test data for task creation test_data = { 'title': 'New Task', @@ -186,6 +186,41 @@ def test_create_new_task(app, client, mock_jwt_identity, mock_db): mock_db.session.add.assert_called_once() mock_db.session.commit.assert_called_once() + +def test_create_new_task_developer_forces_self_assignment(app, mock_jwt_identity, mock_db, mock_jwt): + mock_jwt.return_value = {'role': 'developer'} + + test_data = { + 'title': 'New Task', + 'description': 'Task Description', + 'status': 'todo', + 'progress': 0, + 'assigned_to': 1, + } + + with app.test_request_context(json=test_data): + with patch('backend.src.api.controllers.tasks_controller.Task') as mock_task_class, \ + patch('backend.src.api.controllers.tasks_controller.validate_task_data') as mock_validate: + + mock_validate.return_value = None + + new_task = MagicMock() + new_task.id = 1 + new_task.title = 'New Task' + new_task.status = 'todo' + + mock_task_class.return_value = new_task + + from backend.src.api.controllers.tasks_controller import create_new_task + + response, status_code = create_new_task() + + assert status_code == 201 + assert response.get_json()['task']['title'] == 'New Task' + assert new_task.assigned_to == 1 + mock_db.session.add.assert_called_once() + mock_db.session.commit.assert_called_once() + def test_update_task_by_id(app, mock_jwt_identity, mock_jwt, mock_db, mock_task): # Test data for task update test_data = {'title': 'Updated Task', 'progress': 75} @@ -254,8 +289,11 @@ def test_team_lead_can_assign_unassigned_task(app, mock_jwt_identity, mock_jwt, mock_db.session.commit.assert_called_once() def test_delete_task_by_id(app, mock_jwt_identity, mock_db): + with app.test_request_context(): - with patch('backend.src.api.controllers.tasks_controller.Task.query') as mock_query: + with patch('backend.src.api.controllers.tasks_controller.Task.query') as mock_query, \ + patch('backend.src.api.controllers.tasks_controller.get_jwt') as mock_get_jwt: + mock_get_jwt.return_value = {'role': 'admin'} mock_task = MagicMock() mock_query.get_or_404.return_value = mock_task @@ -270,3 +308,21 @@ def test_delete_task_by_id(app, mock_jwt_identity, mock_db): assert 'Task deleted successfully' in response.get_json()['message'] mock_db.session.delete.assert_called_once_with(mock_task) mock_db.session.commit.assert_called_once() + + +def test_delete_task_permission_denied_for_non_owner(app, mock_jwt_identity, mock_db, mock_jwt): + mock_jwt.return_value = {'role': 'developer'} + + with app.test_request_context(): + with patch('backend.src.api.controllers.tasks_controller.Task.query') as mock_query: + mock_task = MagicMock() + mock_task.assigned_to = 999 + mock_task.created_by = 998 + mock_query.get_or_404.return_value = mock_task + + from backend.src.api.controllers.tasks_controller import delete_task_by_id + + response, status_code = delete_task_by_id(1) + + assert status_code == 403 + assert 'You can only delete tasks assigned to you' in response.get_json()['message'] diff --git a/backend/tests/unit/routes/__init__.py b/backend/tests/unit/routes/__init__.py new file mode 100644 index 0000000..4a5d263 --- /dev/null +++ b/backend/tests/unit/routes/__init__.py @@ -0,0 +1 @@ +# Unit tests package diff --git a/backend/tests/unit/routes/test_admin_routes.py b/backend/tests/unit/routes/test_admin_routes.py index 729d91b..7634e0b 100644 --- a/backend/tests/unit/routes/test_admin_routes.py +++ b/backend/tests/unit/routes/test_admin_routes.py @@ -27,6 +27,7 @@ def app(monkeypatch): monkeypatch.setattr(admin_routes, 'jwt_required', passthrough_decorator) monkeypatch.setattr(admin_routes, 'admin_required', passthrough_decorator) + monkeypatch.setattr(admin_routes, 'role_at_least', passthrough_decorator) monkeypatch.setattr(admin_routes, 'validate_json', passthrough_decorator) monkeypatch.setattr(admin_routes, 'rate_limit', passthrough_decorator) diff --git a/backend/tests/unit/routes/test_comments_routes.py b/backend/tests/unit/routes/test_comments_routes.py index 47bc4e8..4d50756 100644 --- a/backend/tests/unit/routes/test_comments_routes.py +++ b/backend/tests/unit/routes/test_comments_routes.py @@ -26,8 +26,9 @@ def app(monkeypatch): app.config['JWT_SECRET_KEY'] = 'test-secret-key' monkeypatch.setattr(comments_routes, 'jwt_required', passthrough_decorator) - monkeypatch.setattr(comments_routes, 'role_required', passthrough_decorator) - monkeypatch.setattr(comments_routes, 'validate_json', passthrough_decorator) + monkeypatch.setattr(comments_routes, 'role_required', passthrough_decorator, raising=False) + monkeypatch.setattr(comments_routes, 'require_permission', passthrough_decorator, raising=False) + monkeypatch.setattr(comments_routes, 'validate_json', passthrough_decorator, raising=False) bp = Blueprint('api', __name__, url_prefix='/api/v1') comments_routes.register_routes(bp) diff --git a/backend/tests/unit/routes/test_github_routes.py b/backend/tests/unit/routes/test_github_routes.py index ad6c5f1..f865ddd 100644 --- a/backend/tests/unit/routes/test_github_routes.py +++ b/backend/tests/unit/routes/test_github_routes.py @@ -21,7 +21,10 @@ def app(monkeypatch): app.config['JWT_SECRET_KEY'] = 'jwt-secret-key' monkeypatch.setattr(github_routes, 'jwt_required', passthrough_decorator) - monkeypatch.setattr(github_routes, 'validate_json', passthrough_decorator) + monkeypatch.setattr(github_routes, 'validate_json', passthrough_decorator, raising=False) + monkeypatch.setattr(github_routes, 'admin_required', passthrough_decorator, raising=False) + monkeypatch.setattr(github_routes, 'role_required', passthrough_decorator, raising=False) + monkeypatch.setattr(github_routes, 'require_permission', passthrough_decorator, raising=False) bp = Blueprint('api', __name__, url_prefix='/api/v1') github_routes.register_routes(bp) diff --git a/backend/tests/unit/routes/test_notifications_routes.py b/backend/tests/unit/routes/test_notifications_routes.py index 5da5bf9..5499245 100644 --- a/backend/tests/unit/routes/test_notifications_routes.py +++ b/backend/tests/unit/routes/test_notifications_routes.py @@ -21,7 +21,9 @@ def app(monkeypatch): app.config['JWT_SECRET_KEY'] = 'jwt-secret-key' monkeypatch.setattr(notifications_routes, 'jwt_required', passthrough_decorator) - monkeypatch.setattr(notifications_routes, 'validate_json', passthrough_decorator) + monkeypatch.setattr(notifications_routes, 'validate_json', passthrough_decorator, raising=False) + monkeypatch.setattr(notifications_routes, 'require_permission', passthrough_decorator, raising=False) + monkeypatch.setattr(notifications_routes, 'admin_required', passthrough_decorator, raising=False) bp = Blueprint('api', __name__, url_prefix='/api/v1') notifications_routes.register_routes(bp) diff --git a/backend/tests/unit/routes/test_users_routes.py b/backend/tests/unit/routes/test_users_routes.py index e0842d4..bea1535 100644 --- a/backend/tests/unit/routes/test_users_routes.py +++ b/backend/tests/unit/routes/test_users_routes.py @@ -26,9 +26,12 @@ def app(monkeypatch): app.config['JWT_SECRET_KEY'] = 'test-secret-key' monkeypatch.setattr(users_routes, 'jwt_required', passthrough_decorator) - monkeypatch.setattr(users_routes, 'admin_required', passthrough_decorator) - monkeypatch.setattr(users_routes, 'role_required', passthrough_decorator) - monkeypatch.setattr(users_routes, 'validate_json', passthrough_decorator) + monkeypatch.setattr(users_routes, 'admin_required', passthrough_decorator, raising=False) + monkeypatch.setattr(users_routes, 'role_required', passthrough_decorator, raising=False) + monkeypatch.setattr(users_routes, 'role_at_least', passthrough_decorator, raising=False) + monkeypatch.setattr(users_routes, 'validate_json', passthrough_decorator, raising=False) + monkeypatch.setattr(users_routes, 'get_jwt_identity', lambda: 1, raising=False) + monkeypatch.setattr(users_routes, 'get_jwt', lambda: {'role': 'admin'}, raising=False) bp = Blueprint('api', __name__, url_prefix='/api/v1') users_routes.register_routes(bp) diff --git a/backend/tests/unit/services/test_github_client.py b/backend/tests/unit/services/test_github_client.py index 89af0ec..8ce506a 100644 --- a/backend/tests/unit/services/test_github_client.py +++ b/backend/tests/unit/services/test_github_client.py @@ -203,21 +203,26 @@ def test_get_recent_commits_uses_commits_endpoint(self, mock_get): self.assertEqual(called_kwargs['headers'], self.client.get_headers()) @patch('backend.src.services.github_client.requests.get') - def test_get_open_pulls_count_uses_repository_pulls_endpoint(self, mock_get): + def test_get_total_pulls_count_uses_repository_pulls_endpoint(self, mock_get): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = [{'number': 1}] - mock_response.headers = {} + mock_response.headers = { + 'Link': ( + '; rel="next", ' + '; rel="last"' + ) + } mock_get.return_value = mock_response - pull_count = self.client.get_open_pulls_count('owner', 'repo1') + pull_count = self.client.get_total_pulls_count('owner', 'repo1') - # New optimized behavior: returns count from first page only (not paginated) - self.assertEqual(pull_count, 1) + self.assertEqual(pull_count, 42) called_url = mock_get.call_args.args[0] called_kwargs = mock_get.call_args.kwargs self.assertEqual(called_url, 'https://api.github.com/repos/owner/repo1/pulls') - self.assertEqual(called_kwargs['params']['state'], 'open') + self.assertEqual(called_kwargs['params']['state'], 'all') + self.assertEqual(called_kwargs['params']['page'], 1) self.assertEqual(called_kwargs['params']['per_page'], 1) @patch('backend.src.services.github_client.requests.get') @@ -240,7 +245,7 @@ def test_get_open_issues_count_filters_out_pull_requests(self, mock_get): self.assertEqual(called_kwargs['params']['state'], 'open') @patch.object(GitHubClient, 'get_recent_commits', return_value=None) - @patch.object(GitHubClient, 'get_open_pulls_count', return_value=3) + @patch.object(GitHubClient, 'get_total_pulls_count', return_value=3) @patch.object(GitHubClient, 'get_open_issues_count', return_value=None) def test_get_repository_activity_summary_keeps_fallback_issue_count( self, @@ -256,7 +261,7 @@ def test_get_repository_activity_summary_keeps_fallback_issue_count( ) self.assertEqual(summary['open_issues'], 9) - self.assertEqual(summary['open_prs'], 3) + self.assertEqual(summary['total_prs'], 3) self.assertEqual(summary['recent_commits'], 0) def test_parse_state_param_invalid(self): diff --git a/backend/tests/unit/services/test_notification_service.py b/backend/tests/unit/services/test_notification_service.py index f5afa13..34875e4 100644 --- a/backend/tests/unit/services/test_notification_service.py +++ b/backend/tests/unit/services/test_notification_service.py @@ -11,7 +11,7 @@ def mock_db_session(): @pytest.fixture def mock_emit(): - with patch('src.services.notification_service.emit') as mock: + with patch('src.services.notification_service.socketio.emit') as mock: yield mock @pytest.fixture @@ -63,14 +63,17 @@ def mock_add(notification): mock_db_session.commit.assert_called_once() # Verify the emit was called with right parameters - mock_emit.assert_called_once_with('notification', { - 'id': 1, - 'type': notification_data['notification_type'], - 'title': notification_data['title'], - 'message': notification_data['message'], - 'reference_id': notification_data['reference_id'], - 'timestamp': mock_notification.created_at.isoformat() - }, to='socket1') + mock_emit.assert_called_once() + event_name, payload = mock_emit.call_args.args + assert event_name == 'notification' + assert payload['id'] == 1 + assert payload['type'] == notification_data['notification_type'] + assert payload['title'] == notification_data['title'] + assert payload['message'] == notification_data['message'] + assert payload['content'] == notification_data['message'] + assert payload['reference_id'] == notification_data['reference_id'] + assert payload['timestamp'] == mock_notification.created_at.isoformat() + assert mock_emit.call_args.kwargs == {'to': 'socket1'} def test_send_to_user_not_connected(mock_db_session, mock_emit, mock_connected_users, notification_data): @@ -127,7 +130,7 @@ def test_send_to_project(mock_project_rooms, notification_data): from src.services.notification_service import NotificationService with patch.object(NotificationService, 'send_to_user') as mock_send_to_user: - mock_send_to_user.return_value = MagicMock() + mock_send_to_user.return_value = object() # Call the method results = NotificationService.send_to_project( @@ -143,14 +146,14 @@ def test_send_to_project(mock_project_rooms, notification_data): mock_send_to_user.assert_has_calls([ call(user_id='user1', notification_type=notification_data['notification_type'], title=notification_data['title'], message=notification_data['message'], - reference_id=notification_data['reference_id']), + reference_id=notification_data['reference_id'], task_id=None), call(user_id='user2', notification_type=notification_data['notification_type'], title=notification_data['title'], message=notification_data['message'], - reference_id=notification_data['reference_id']), + reference_id=notification_data['reference_id'], task_id=None), call(user_id='user3', notification_type=notification_data['notification_type'], title=notification_data['title'], message=notification_data['message'], - reference_id=notification_data['reference_id']) - ]) + reference_id=notification_data['reference_id'], task_id=None) + ], any_order=True) # Test exclusion logic mock_send_to_user.reset_mock() @@ -236,4 +239,4 @@ def test_get_user_notifications(): result = NotificationService.get_user_notifications(user_id=1, page=2, per_page=20, unread_only=True) # Should check mock_filter.filter_by (not mock_query.filter_by) since we call filter_by twice mock_filter.filter_by.assert_called_with(is_read=False) - mock_order.paginate.assert_called_with(page=2, per_page=20, error_out=False) \ No newline at end of file + mock_order.paginate.assert_called_with(page=2, per_page=20, error_out=False) diff --git a/backend/tests/unit/validators/test_auth_validator.py b/backend/tests/unit/validators/test_auth_validator.py index e8b068b..6c5a19f 100644 --- a/backend/tests/unit/validators/test_auth_validator.py +++ b/backend/tests/unit/validators/test_auth_validator.py @@ -80,16 +80,7 @@ def test_registration_validation(self): assert code == 400 assert json.loads(response.data)['message'] == 'Password must be at least 8 characters long' - # Test invalid role - invalid_role = { - 'name': 'Test User', - 'email': 'test@example.com', - 'password': 'password123', - 'role': 'superuser' - } - response, code = validate_registration_data(invalid_role) - assert code == 400 - assert 'Role must be one of' in json.loads(response.data)['message'] + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/docs/backend/rbac.md b/docs/backend/rbac.md index ad6e959..5f8006f 100644 --- a/docs/backend/rbac.md +++ b/docs/backend/rbac.md @@ -6,93 +6,196 @@ This document outlines the RBAC system implemented in DevSync, detailing the rol DevSync has three primary roles with increasing levels of permission: -1. **Developer** - Basic user with limited permissions focused on task management -2. **Team Lead** - Extended permissions for task creation and team management -3. **Admin** - Full system access including user management and system settings +1. **Developer** (`developer`) — Basic user with limited permissions focused on task management +2. **Team Lead** (`team_lead`) — Extended permissions for task creation and team management +3. **Admin** (`admin`) — Full system access including user management and system settings + +### Role Hierarchy + +Roles are ranked numerically so higher roles inherit all lower privileges: + +| Role | Level | +|------|-------| +| Developer | 0 | +| Team Lead | 1 | +| Admin | 2 | + +The `role_at_least(min_role)` decorator uses this hierarchy to enforce minimum-level access. ## Permissions by Role ### Developer Permissions -- View assigned tasks and created tasks -- Update tasks assigned to them -- Add comments to tasks -- View and manage personal notifications -- View and update their own profile -- Link GitHub account +- View all tasks +- Create and update tasks assigned to them +- Add comments to all tasks (`can_comment_on_tasks`) +- View and manage personal notifications (`can_manage_personal_notifications`) +- Link personal GitHub account (`can_link_github_account`) ### Team Lead Permissions All Developer permissions, plus: -- Create new tasks -- Assign tasks to team members -- View team statistics and metrics -- Generate reports -- View all team member profiles +- Create new tasks / assign tasks (`can_assign_tasks`) +- Update any task (`can_update_any_task`) +- View all users (`can_view_all_users`) +- Manage projects (`can_manage_projects`) +- Generate and view reports +- View developer progress ### Admin Permissions All Team Lead permissions, plus: -- Delete tasks -- Manage users (create, update, delete) -- Modify system settings -- View audit logs -- Access all data in the system +- Manage users — create, update, delete (`can_manage_users`) +- Modify system settings (`can_manage_system_settings`) +- View and query audit logs ## API Endpoint Permission Mapping -| Endpoint | Method | Required Permission | Minimum Role | -|----------|--------|---------------------|-------------| -| `/api/auth/register` | POST | (public) | N/A | -| `/api/auth/login` | POST | (public) | N/A | -| `/api/auth/refresh` | POST | (authenticated) | Any | -| `/api/auth/logout` | POST | (authenticated) | Any | -| `/api/auth/me` | GET | (authenticated) | Any | -| `/api/tasks` | GET | can_view_tasks | Developer | -| `/api/tasks` | POST | can_create_tasks | Team Lead | -| `/api/tasks/:id` | GET | can_view_tasks | Developer | -| `/api/tasks/:id` | PUT | can_update_assigned_tasks | Developer | -| `/api/tasks/:id` | DELETE | can_delete_tasks | Admin | -| `/api/tasks/:id/comments` | GET | can_comment | Developer | -| `/api/tasks/:id/comments` | POST | can_comment | Developer | -| `/api/admin/users` | GET | can_manage_users | Admin | -| `/api/admin/users/:id` | PUT | can_manage_users | Admin | -| `/api/admin/users/:id` | DELETE | can_manage_users | Admin | -| `/api/admin/system/settings` | GET | can_manage_system_settings | Admin | -| `/api/admin/system/settings` | PUT | can_manage_system_settings | Admin | +| Endpoint | Method | Permission / Guard | Minimum Role | +|----------|--------|--------------------|--------------| +| `/api/v1/auth/register` | POST | (public) | N/A | +| `/api/v1/auth/login` | POST | (public) | N/A | +| `/api/v1/auth/refresh` | POST | (authenticated) | Any | +| `/api/v1/auth/logout` | POST | (authenticated) | Any | +| `/api/v1/auth/me` | GET | (authenticated) | Any | +| `/api/v1/auth/permissions` | GET | (authenticated) | Any | +| `/api/v1/tasks` | GET | (authenticated) | Developer | +| `/api/v1/tasks` | POST | `role_required([TEAM_LEAD, ADMIN])` | Team Lead | +| `/api/v1/tasks/:id` | GET | (authenticated) | Developer | +| `/api/v1/tasks/:id` | PUT | (authenticated — controller enforces ownership) | Developer | +| `/api/v1/tasks/:id` | DELETE | `role_required([ADMIN])` | Admin | +| `/api/v1/tasks/:id/comments` | GET/POST | `require_permission('can_comment_on_tasks')` | Developer | +| `/api/v1/users` | GET | `role_at_least(TEAM_LEAD)` | Team Lead | +| `/api/v1/users/:id` | GET | self or `role_at_least(TEAM_LEAD)` | Developer (self) | +| `/api/v1/projects` | GET | (authenticated) | Developer | +| `/api/v1/projects` | POST | `role_required([TEAM_LEAD, ADMIN])` | Team Lead | +| `/api/v1/projects/:id` | PUT | `role_required([TEAM_LEAD, ADMIN])` | Team Lead | +| `/api/v1/projects/:id` | DELETE | `role_required([TEAM_LEAD, ADMIN])` | Team Lead | +| `/api/v1/admin/stats` | GET | `role_at_least(TEAM_LEAD)` | Team Lead | +| `/api/v1/admin/settings` | GET/PUT | `admin_required` | Admin | +| `/api/v1/admin/users` | GET | `admin_required` | Admin | +| `/api/v1/admin/users/:id` | PUT/DELETE | `admin_required` | Admin | +| `/api/v1/admin/users/:id/role` | PUT | `admin_required` | Admin | +| `/api/v1/admin/audit-logs` | GET | `admin_required` | Admin | +| `/api/v1/admin/audit-logs/:id` | GET | `admin_required` | Admin | +| `/api/v1/github/repositories` | POST | `role_required([ADMIN])` + `require_permission('can_link_github_repos')` | Admin | +| `/api/v1/notifications/:id` | DELETE | `require_permission('can_manage_personal_notifications')` | Developer | +| `/api/v1/dashboard/client` | GET | `role_required([DEVELOPER, TEAM_LEAD])` | Developer | +| `/api/v1/dashboard/admin` | GET | `role_required([ADMIN])` | Admin | ## Implementation Details -The RBAC system is implemented using: +### Backend Decorators + +The RBAC system is implemented in `backend/src/auth/rbac.py` using: + +1. **`role_required(allowed_roles)`** — Restricts access to an explicit list of roles. +2. **`admin_required`** — Shorthand for admin-only routes. +3. **`role_at_least(min_role)`** — Hierarchical check using `ROLE_HIERARCHY`. +4. **`require_permission(permission)`** — Granular permission-based check using `ROLE_PERMISSIONS`. + +```python +from src.auth.rbac import role_required, role_at_least, require_permission, Role + +# Only admins +@role_required([Role.ADMIN]) +def admin_only_route(): + ... + +# Team Lead or higher +@role_at_least(Role.TEAM_LEAD) +def team_lead_plus_route(): + ... + +# Anyone with the specific permission +@require_permission('can_assign_tasks') +def assign_task(): + ... +``` + +### Audit Service + +All sensitive actions are logged via `audit_service.record(...)`: + +```python +from src.services import audit_service + +audit_service.record( + action='user_role_changed', + resource_type='user', + resource_id=user.id, + metadata={'old_role': 'developer', 'new_role': 'admin'} +) +``` + +Recorded actions include: `user_registered`, `user_login`, `user_deleted`, `user_role_changed`, `settings_updated`, `project_created`, `project_deleted`, `task_deleted`. -1. JWT tokens with role claims -2. Custom permission decorators for route protection -3. Role hierarchy enforcement -4. Permission validation middleware +The audit service never crashes the request — failures are logged and silently rolled back. + +### Settings Service + +System settings are stored in the `system_settings` table and accessed via `settings_service`: + +```python +from src.services import settings_service + +all_settings = settings_service.get_settings() +default_role = settings_service.get_default_role() +settings_service.update_settings({'allow_registration': False}, actor_id=1) +``` + +### Registration Security + +- The `role` field in the registration body is **ignored**. +- All new users are created with the default role from `settings_service.get_default_role()` (defaults to `developer`). +- Only admins can promote users via `PUT /api/v1/admin/users/:id/role`. ## Frontend Integration -For frontend developers: +### `useAuth()` Context + +The `AuthContext` provides RBAC helpers after login: + +```jsx +const { currentUser, can, is, permissions } = useAuth(); + +// Check permission +if (can('can_manage_users')) { /* show admin panel */ } + +// Check role +if (is('admin')) { /* admin-specific UI */ } +``` + +Permissions are fetched from `GET /api/v1/auth/permissions` on login and cached in `localStorage`. + +### `ProtectedRoute` Component + +Routes are guarded with role or permission checks: + +```jsx + + + + + + + +``` -- Use the token's decoded payload to determine user role -- Hide UI elements based on permissions (not just disable them) -- Handle 403 responses by redirecting to appropriate error pages -- Refresh tokens when approaching expiration -- Clear tokens and redirect to login when access is denied +Unauthorized users are redirected to `/forbidden`. -### Handling Permission Errors +### `rbac.js` Utilities -When a user attempts an unauthorised action, the API returns: +`frontend/src/utils/rbac.js` exports: `ROLES`, `ROLE_HIERARCHY`, `PERMISSIONS`, `hasRole`, `hasAnyRole`, `roleAtLeast`, `hasPermission`. -- HTTP status code 403 -- JSON response with "message" field explaining the error +### 403 Handling -Frontend should: +- `api.js` intercepts all `403` responses and redirects to `/forbidden`. +- The `Forbidden` page shows a clear message and a button back to the dashboard. +- **Never retry** a 403 — the server will not change its mind. -1. Display appropriate error message -2. Not attempt to retry the request -3. Guide the user to appropriate actions they are permitted to take +### Navbar -## Example RBAC Usage in Code +The Navbar uses a three-branch render (`admin` / `team_lead` / `developer`), driven by `can(...)` and `is(...)` helpers. Admin sees Users, Settings, and Audit Logs links. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c581401..1566284 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,10 @@ import { AuthProvider, useAuth } from "./context/AuthContext"; import { NotificationProvider } from "./context/NotificationContext"; import Register from "./pages/Register"; import GitHubCallback from './pages/GitHubCallback'; +import Forbidden from './pages/Forbidden'; +import AdminUsers from './pages/AdminUsers'; +import AdminSystemSettings from './pages/AdminSystemSettings'; +import AdminAuditLogs from './pages/AdminAuditLogs'; const ROLES = { DEVELOPER: 'developer', @@ -29,13 +33,14 @@ const ROLES = { const MEMBER_ROLES = [ROLES.DEVELOPER, ROLES.TEAM_LEAD]; const AUTHENTICATED_ROLES = [ROLES.DEVELOPER, ROLES.TEAM_LEAD, ROLES.ADMIN]; -const TASK_CREATOR_ROLES = [ROLES.TEAM_LEAD, ROLES.ADMIN]; +const TASK_CREATOR_ROLES = [ROLES.DEVELOPER, ROLES.TEAM_LEAD, ROLES.ADMIN]; +const TEAM_LEAD_OR_ADMIN = [ROLES.TEAM_LEAD, ROLES.ADMIN]; +// Team Leads should see the basic developer dashboard by default. const getDashboardPath = (role) => (role === ROLES.ADMIN ? '/admin' : '/BasicDashboard'); -// Protected route wrapper component - Completely rewritten to prevent infinite loops -const ProtectedRoute = ({ children, allowedRoles = [] }) => { - const { currentUser, loading } = useAuth(); +const ProtectedRoute = ({ children, allowedRoles = [], requiredPermission = null }) => { + const { currentUser, loading, can } = useAuth(); // Show loading state while authentication is being checked if (loading) { @@ -62,9 +67,13 @@ const ProtectedRoute = ({ children, allowedRoles = [] }) => { // If allowedRoles is specified, check if the user has the required role if (allowedRoles.length > 0 && !allowedRoles.includes(currentUser.role)) { console.log(`User role ${currentUser.role} not allowed for this route`); - - // Redirect to the appropriate dashboard based on role - return ; + return ; + } + + // If requiredPermission is specified, check if user has the permission + if (requiredPermission && !can(requiredPermission)) { + console.log(`User lacks permission ${requiredPermission} for this route`); + return ; } // All checks passed, render the protected component @@ -112,6 +121,8 @@ function AppRoutes() { ) } /> + } /> + {/* Developer and Team Lead Routes */} @@ -168,13 +179,13 @@ function AppRoutes() { {/* Admin Routes (Project Managers) */} + } /> + } /> @@ -186,35 +197,53 @@ function AppRoutes() { } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + + + + } /> + + + + + } /> + diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index c782ff7..6265fe8 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -5,8 +5,8 @@ import { useState, useEffect } from "react"; import Notifications from "./Notifications"; const Navbar = () => { - const { currentUser, logout } = useAuth(); - const { notifications, unreadCount, markAsRead, markAllAsRead, refreshNotifications, isConnected } = useNotifications(); + const { currentUser, logout, is } = useAuth(); + const { notifications, unreadCount, markAsRead, markAllAsRead, refreshNotifications } = useNotifications(); const [isLoggingOut, setIsLoggingOut] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false); const [showNotifications, setShowNotifications] = useState(false); @@ -45,182 +45,175 @@ const Navbar = () => { if (!currentUser) return null; - const isAdmin = currentUser.role === "admin"; - const canCreateTasks = isAdmin || currentUser.role === "team_lead"; + const isAdmin = is('admin'); + const isTeamLead = is('team_lead'); + const dashboardPath = isAdmin ? '/admin' : '/BasicDashboard'; return ( - + ); }; diff --git a/frontend/src/components/Notifications.jsx b/frontend/src/components/Notifications.jsx index 83c00cf..6f2e480 100644 --- a/frontend/src/components/Notifications.jsx +++ b/frontend/src/components/Notifications.jsx @@ -1,15 +1,30 @@ -import React from 'react'; +import React, { useState } from 'react'; import { notificationService } from '../services/utils/api'; import { useNotifications } from '../context/NotificationContext'; -function Notifications({ notifications = [], onNotificationUpdate }) { - const { isLoading, error, rateLimited, refreshNotifications } = useNotifications(); +function Notifications({ notifications = [], onNotificationUpdate, onMarkRead, onDelete }) { + const [deletingId, setDeletingId] = useState(null); + const { + isLoading, + error, + rateLimited, + refreshNotifications, + markAsRead: markContextAsRead, + deleteNotification: deleteContextNotification + } = useNotifications(); const handleNotificationClick = async (notificationId) => { if (!notificationId) return; try { - await notificationService.markAsRead(notificationId); + if (typeof onMarkRead === 'function') { + await onMarkRead(notificationId); + } else if (typeof markContextAsRead === 'function') { + await markContextAsRead(notificationId); + } else { + await notificationService.markAsRead(notificationId); + } + // Callback to parent to refresh notifications if (typeof onNotificationUpdate === 'function') { onNotificationUpdate(); @@ -19,6 +34,31 @@ function Notifications({ notifications = [], onNotificationUpdate }) { } }; + const handleDeleteNotification = async (event, notificationId) => { + event.stopPropagation(); + if (!notificationId || deletingId === notificationId) return; + + try { + setDeletingId(notificationId); + + if (typeof onDelete === 'function') { + await onDelete(notificationId); + } else if (typeof deleteContextNotification === 'function') { + await deleteContextNotification(notificationId); + } else { + await notificationService.deleteNotification(notificationId); + } + + if (typeof onNotificationUpdate === 'function') { + onNotificationUpdate(); + } + } catch (error) { + console.error('Failed to delete notification:', error); + } finally { + setDeletingId(null); + } + }; + // Handle rate limited state if (rateLimited) { return ( @@ -91,7 +131,9 @@ function Notifications({ notifications = [], onNotificationUpdate }) { const notificationId = notification?.id || `notification-${Math.random().toString(36).substr(2, 9)}`; const isRead = notification?.is_read || notification?.read || false; const content = notification?.content || notification?.message || 'No content'; - const createdAt = notification?.created_at ? new Date(notification.created_at).toLocaleDateString() : 'Unknown date'; + const title = notification?.title; + const timestamp = notification?.created_at || notification?.timestamp; + const createdAt = timestamp ? new Date(timestamp).toLocaleDateString() : 'Unknown date'; return (
handleNotificationClick(notificationId)} >
-
{content}
+
+ {title &&
{title}
} +
{content}
+
{createdAt}
+
+ +
); })} @@ -113,4 +168,4 @@ function Notifications({ notifications = [], onNotificationUpdate }) { ); } -export default Notifications; \ No newline at end of file +export default Notifications; diff --git a/frontend/src/components/ReportTable.jsx b/frontend/src/components/ReportTable.jsx index 36d6b32..df9951c 100644 --- a/frontend/src/components/ReportTable.jsx +++ b/frontend/src/components/ReportTable.jsx @@ -70,7 +70,7 @@ const ReportTable = ({ data = [], type }) => { case 'Actions': return ( View @@ -91,7 +91,7 @@ const ReportTable = ({ data = [], type }) => { case 'Issues': return item.open_issues ?? item.open_issues_count ?? 0; case 'PRs': - return item.open_prs || 0; + return item.total_prs || 0; case 'Commits': return item.recent_commits || 0; case 'Last Updated': @@ -173,15 +173,15 @@ const ReportTable = ({ data = [], type }) => { if (!data || data.length === 0) { return ( -
+
No data available for this report
); } return ( -
-
+
+
diff --git a/frontend/src/components/TaskColumns.jsx b/frontend/src/components/TaskColumns.jsx index f98f5dc..58ca270 100644 --- a/frontend/src/components/TaskColumns.jsx +++ b/frontend/src/components/TaskColumns.jsx @@ -71,9 +71,9 @@ function TaskColumns({ tasks = [] }) { ) : ( - {taskPriority === 'high' ? '❗ High' : - taskPriority === 'medium' ? '⚠️ Medium' : '🔽 Low'} - + {taskPriority === 'high' ? 'High' : + taskPriority === 'medium' ? 'Medium' : 'Low'} + )} diff --git a/frontend/src/components/TaskForm.jsx b/frontend/src/components/TaskForm.jsx index 938c151..d1bec29 100644 --- a/frontend/src/components/TaskForm.jsx +++ b/frontend/src/components/TaskForm.jsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; -const TaskForm = ({ onSubmit, initialData = {}, users = [], projects = [] }) => { +const TaskForm = ({ onSubmit, initialData = {}, users = [], projects = [], assigneeLocked = false }) => { const [task, setTask] = useState({ title: initialData.title || "", description: initialData.description || "", @@ -11,6 +11,15 @@ const TaskForm = ({ onSubmit, initialData = {}, users = [], projects = [] }) => priority: initialData.priority || "medium" }); + useEffect(() => { + if (assigneeLocked && task.assignee === "" && users.length > 0) { + setTask((currentTask) => ({ + ...currentTask, + assignee: initialData.assigned_to || users[0].id, + })); + } + }, [assigneeLocked, initialData.assigned_to, task.assignee, users]); + const statusOptions = [ { value: "todo", label: "To Do" }, { value: "in_progress", label: "In Progress" }, @@ -64,6 +73,7 @@ const TaskForm = ({ onSubmit, initialData = {}, users = [], projects = [] }) => id="assignee" value={task.assignee} onChange={(e) => setTask({ ...task, assignee: e.target.value })} + disabled={assigneeLocked} className="w-full p-2 border border-slate-700/60 rounded bg-slate-950/60 text-slate-100 focus:ring-rose-400/60 focus:border-rose-400/60" > @@ -73,6 +83,9 @@ const TaskForm = ({ onSubmit, initialData = {}, users = [], projects = [] }) => ))} + {assigneeLocked && ( +

Developers can only create tasks for themselves.

+ )}
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index a13d0ff..1d5c5a3 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,7 +1,9 @@ import { createContext, useState, useContext, useEffect, useRef } from 'react'; import { authApi } from '../services/utils/auth'; import { githubService } from '../services/github'; +import { dashboardService } from '../services/utils/api'; import { useNavigate, useLocation } from 'react-router-dom'; +import { hasRole, hasPermission } from '../utils/rbac'; const AuthContext = createContext(); @@ -9,6 +11,7 @@ export const useAuth = () => useContext(AuthContext); const VALID_ROLES = new Set(["developer", "team_lead", "admin"]); const DEFAULT_ROLE = "developer"; +const REPORT_WARMUP_ROLES = new Set(["team_lead", "admin"]); const hasValidRole = (user) => user?.role && VALID_ROLES.has(user.role); @@ -38,9 +41,11 @@ export const AuthProvider = ({ children }) => { const [githubConnected, setGithubConnected] = useState(initialUser?.github_connected || false); const [showGithubPrompt, setShowGithubPrompt] = useState(false); const [authInProgress, setAuthInProgress] = useState(false); + const [permissions, setPermissions] = useState(initialUser?.permissions || []); // Keep a ref to track initialization const isInitialized = useRef(false); + const reportWarmupKeyRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); @@ -72,6 +77,22 @@ export const AuthProvider = ({ children }) => { if (verifyToken(user) && hasValidRole(user)) { // Update the state with the user setCurrentUser(user); + if (user.permissions) { + setPermissions(user.permissions); + } else { + // Fetch permissions if not in localStorage + fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:8000/api/v1'}/auth/permissions`, { + headers: { 'Authorization': `Bearer ${user.token}` } + }) + .then(res => res.json()) + .then(data => { + setPermissions(data.permissions || []); + const updatedUser = { ...user, permissions: data.permissions || [] }; + localStorage.setItem('user', JSON.stringify(updatedUser)); + setCurrentUser(updatedUser); + }) + .catch(err => console.error("Failed to fetch permissions:", err)); + } // Set GitHub connection status if available if ("github_connected" in user) { setGithubConnected(user.github_connected); @@ -153,6 +174,19 @@ export const AuthProvider = ({ children }) => { throw new Error("This account role is no longer supported. Please contact an administrator."); } + // Fetch permissions + try { + const permRes = await fetch(`${process.env.REACT_APP_API_URL || 'http://localhost:8000/api/v1'}/auth/permissions`, { + headers: { 'Authorization': `Bearer ${userWithToken.token}` } + }); + const permData = await permRes.json(); + userWithToken.permissions = permData.permissions || []; + setPermissions(permData.permissions || []); + } catch (err) { + console.error("Failed to fetch permissions during login:", err); + userWithToken.permissions = []; + } + // Save to localStorage first to ensure persistence localStorage.setItem("user", JSON.stringify(userWithToken)); @@ -226,8 +260,10 @@ export const AuthProvider = ({ children }) => { } catch (err) { console.error("Logout error:", err); } finally { + dashboardService.clearReportDataCache(); localStorage.removeItem("user"); setCurrentUser(null); + setPermissions([]); setGithubConnected(false); setShowGithubPrompt(false); setAuthInProgress(false); @@ -265,6 +301,7 @@ export const AuthProvider = ({ children }) => { // Update GitHub connection status if that information is included if (userData && "github_connected" in userData) { + dashboardService.clearReportDataCache(); setGithubConnected(userData.github_connected); if (userData.github_connected) { // If GitHub is now connected, make sure to hide the prompt @@ -394,9 +431,58 @@ export const AuthProvider = ({ children }) => { verify(); }, [currentUserId, currentGithubConnected]); + useEffect(() => { + const canAccessReports = currentUser?.role && REPORT_WARMUP_ROLES.has(currentUser.role); + const isGithubReady = Boolean(currentUser?.github_connected || githubConnected); + + if (!currentUserId || !canAccessReports || !isGithubReady) { + return undefined; + } + + const warmupKey = `${currentUserId}:github:week`; + if (reportWarmupKeyRef.current === warmupKey) { + return undefined; + } + + let cancelled = false; + const runWarmup = () => { + if (cancelled) { + return; + } + + reportWarmupKeyRef.current = warmupKey; + dashboardService.prefetchReportData('github', 'week').catch((error) => { + console.error('Error warming GitHub report data', error); + }); + }; + + if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { + const idleId = window.requestIdleCallback(runWarmup, { timeout: 5000 }); + return () => { + cancelled = true; + if (typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(idleId); + } + }; + } + + const timeoutId = window.setTimeout(runWarmup, 1000); + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [currentUserId, currentUser?.role, currentUser?.github_connected, githubConnected]); + + // RBAC Helpers + const can = (permission) => hasPermission(permissions, permission); + const is = (role) => hasRole(currentUser?.role, role); + const value = { currentUser, setCurrentUser: updateUser, + permissions, + can, + is, login, register, logout, diff --git a/frontend/src/context/NotificationContext.jsx b/frontend/src/context/NotificationContext.jsx index d2a4af1..fd35f99 100644 --- a/frontend/src/context/NotificationContext.jsx +++ b/frontend/src/context/NotificationContext.jsx @@ -13,6 +13,8 @@ const RECONNECT_RETRY_DELAY = _isTestEnv ? 50 : 60000; // ms export const useNotifications = () => useContext(NotificationContext); +const isNotificationRead = (notification) => Boolean(notification?.is_read || notification?.read); + export const NotificationProvider = ({ children }) => { const [notifications, setNotifications] = useState([]); const [isConnected, setIsConnected] = useState(false); @@ -223,6 +225,14 @@ export const NotificationProvider = ({ children }) => { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } + + if (typeof socketConnection.emit === 'function') { + socketConnection.emit('register', {}, (ack) => { + if (ack?.status && ack.status !== 'success') { + console.warn('Socket.IO registration did not succeed:', ack); + } + }); + } }); socketConnection.on('disconnect', () => { @@ -323,7 +333,7 @@ export const NotificationProvider = ({ children }) => { }, [currentUser, refreshNotifications, serverDown]); // Calculate unread count - const unreadCount = notifications.filter(notification => !notification.read).length; + const unreadCount = notifications.filter(notification => !isNotificationRead(notification)).length; // Mark a notification as read const markAsRead = async (notificationId) => { @@ -334,9 +344,9 @@ export const NotificationProvider = ({ children }) => { try { // Optimistic UI update - setNotifications(notifications.map(notification => + setNotifications(prev => prev.map(notification => notification.id === notificationId - ? { ...notification, read: true } + ? { ...notification, read: true, is_read: true } : notification )); @@ -348,6 +358,23 @@ export const NotificationProvider = ({ children }) => { refreshNotifications(true); } }; + + const deleteNotification = async (notificationId) => { + if (serverDown) { + console.log('Server appears to be down, cannot delete notification'); + return; + } + + const previousNotifications = notifications; + + try { + setNotifications((prev) => prev.filter((notification) => notification.id !== notificationId)); + await notificationService.deleteNotification(notificationId); + } catch (error) { + console.error('Failed to delete notification:', error); + setNotifications(previousNotifications); + } + }; // Mark all notifications as read const markAllAsRead = async () => { @@ -358,7 +385,7 @@ export const NotificationProvider = ({ children }) => { try { // Update UI immediately for better UX - setNotifications(notifications.map(notification => ({ ...notification, read: true }))); + setNotifications(prev => prev.map(notification => ({ ...notification, read: true, is_read: true }))); // API call to mark all as read await notificationService.markAllAsRead(); @@ -381,6 +408,7 @@ export const NotificationProvider = ({ children }) => { notifications, unreadCount, markAsRead, + deleteNotification, markAllAsRead, refreshNotifications, isConnected, @@ -398,4 +426,4 @@ export const NotificationProvider = ({ children }) => { ); }; -export default NotificationContext; \ No newline at end of file +export default NotificationContext; diff --git a/frontend/src/pages/AdminAuditLogs.jsx b/frontend/src/pages/AdminAuditLogs.jsx new file mode 100644 index 0000000..1e9fb4d --- /dev/null +++ b/frontend/src/pages/AdminAuditLogs.jsx @@ -0,0 +1,161 @@ +import { useState, useEffect, useCallback } from 'react'; +import { auditLogService } from '../services/utils/api'; + +const AdminAuditLogs = () => { + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [pages, setPages] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [actionFilter, setActionFilter] = useState(''); + const [actorFilter, setActorFilter] = useState(''); + const [selectedLog, setSelectedLog] = useState(null); + + const perPage = 25; + + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const filters = { page, per_page: perPage }; + if (actionFilter) filters.action = actionFilter; + if (actorFilter) filters.actor = actorFilter; + const data = await auditLogService.getLogs(filters); + setLogs(data.logs || []); + setTotal(data.total || 0); + setPages(data.pages || 0); + } catch (err) { + console.error('Failed to fetch audit logs', err); + } finally { + setLoading(false); + } + }, [page, actionFilter, actorFilter]); + + useEffect(() => { fetchLogs(); }, [fetchLogs]); + + const handleViewDetail = async (logId) => { + const detail = await auditLogService.getLogById(logId); + setSelectedLog(detail); + }; + + const actionBadge = (action) => { + if (action?.includes('login')) return 'bg-blue-500/20 text-blue-300'; + if (action?.includes('register')) return 'bg-emerald-500/20 text-emerald-300'; + if (action?.includes('delete')) return 'bg-rose-500/20 text-rose-300'; + if (action?.includes('role')) return 'bg-amber-500/20 text-amber-300'; + return 'bg-slate-500/20 text-slate-300'; + }; + + return ( +
+
+

Audit Logs

+ + {/* Filters */} +
+ { setActionFilter(e.target.value); setPage(1); }} + className="flex-1 rounded-lg border border-slate-700/60 bg-slate-900/80 py-2 px-3 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-rose-400/60" /> + { setActorFilter(e.target.value); setPage(1); }} + className="w-40 rounded-lg border border-slate-700/60 bg-slate-900/80 py-2 px-3 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-rose-400/60" /> +
+ + {/* Table */} + {loading ? ( +
Loading audit logs...
+ ) : ( + <> +
+
+ + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + {logs.length === 0 && ( + + )} + +
TimeActionActorResourceDetails
+ {log.created_at ? new Date(log.created_at).toLocaleString() : '—'} + + + {log.action} + + + {log.actor_name || (log.actor_user_id ? `User ${log.actor_user_id}` : 'System')} + {log.actor_role && ({log.actor_role})} + + {log.resource_type || '—'}{log.resource_id ? ` #${log.resource_id}` : ''} + + +
No audit logs found.
+
+ + {/* Pagination */} +
+ Total: {total} entries +
+ + Page {page} of {pages || 1} + +
+
+ + )} + + {/* Detail Drawer */} + {selectedLog && ( +
+
+

Audit Log Detail

+
+ {[['ID', selectedLog.id], ['Action', selectedLog.action], ['Actor', selectedLog.actor_name || selectedLog.actor_user_id], + ['Role', selectedLog.actor_role], ['Resource', `${selectedLog.resource_type || ''} ${selectedLog.resource_id || ''}`], + ['IP', selectedLog.ip], ['User Agent', selectedLog.user_agent], + ['Timestamp', selectedLog.created_at ? new Date(selectedLog.created_at).toLocaleString() : '—'] + ].map(([label, val]) => ( +
+
{label}
+
{val || '—'}
+
+ ))} + {selectedLog.metadata && ( +
+
Metadata
+
{JSON.stringify(selectedLog.metadata, null, 2)}
+
+ )} +
+
+ +
+
+
+ )} +
+
+ ); +}; + +export default AdminAuditLogs; diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx index 79d556f..ff7ae15 100644 --- a/frontend/src/pages/AdminDashboard.jsx +++ b/frontend/src/pages/AdminDashboard.jsx @@ -1,48 +1,50 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { dashboardService, projectService } from '../services/utils/api'; +import { dashboardService, projectService, userService, auditLogService } from '../services/utils/api'; import LoadingSpinner from '../components/LoadingSpinner'; +import { useAuth } from '../context/AuthContext'; // ─── Stat Card ──────────────────────────────────────────────────────────────── const StatCard = ({ title, value, change, icon, color, currentTimeRange }) => { - const iconClass = { - primary: 'bg-rose-500/10 text-rose-300 border border-rose-400/20', - secondary: 'bg-sky-500/10 text-sky-300 border border-sky-400/20', - success: 'bg-emerald-500/10 text-emerald-300 border border-emerald-400/20', - warning: 'bg-amber-500/10 text-amber-300 border border-amber-400/20', - error: 'bg-rose-500/10 text-rose-300 border border-rose-400/20', - }[color] ?? 'bg-slate-800 text-slate-300 border border-slate-700'; + const colorClasses = { + primary: 'bg-sky-500/15 text-sky-300 border border-sky-400/20', + secondary: 'bg-rose-500/15 text-rose-300 border border-rose-400/20', + success: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + warning: 'bg-amber-500/15 text-amber-300 border border-amber-400/20', + error: 'bg-rose-500/15 text-rose-300 border border-rose-400/20', + purple: 'bg-purple-500/15 text-purple-300 border border-purple-400/20', + }; return ( -
-
-
+
+
+
{icon}
-
-

{title}

-

{value}

+
+

{title}

+

{value}

{change !== undefined && ( -

+

{change > 0 ? ( - - - + + + - {change}% increase + {change}% ) : change < 0 ? ( - - - + + + - {Math.abs(change)}% decrease + {Math.abs(change)}% ) : ( No change )} - since last {currentTimeRange} -

+ vs last {currentTimeRange} +
)}
@@ -73,7 +75,7 @@ const StatusBadge = ({ status }) => { // ─── Panel wrapper ───────────────────────────────────────────────────────────── const Panel = ({ children, className = '' }) => ( -
+
{children}
); @@ -91,7 +93,10 @@ const PanelHeader = ({ title, linkTo, linkLabel }) => ( // ─── Admin Dashboard ─────────────────────────────────────────────────────────── const AdminDashboard = () => { + const { currentUser } = useAuth(); const [dashboardData, setDashboardData] = useState(null); + const [teamUsers, setTeamUsers] = useState([]); + const [recentAuditLogs, setRecentAuditLogs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [timeRange, setTimeRange] = useState('week'); @@ -101,6 +106,27 @@ const AdminDashboard = () => { setLoading(true); const data = await dashboardService.getAdminDashboardStats(timeRange); setDashboardData(data); + + // Fetch team users for the overview + try { + const users = await userService.getAllUsers(); + setTeamUsers(users || []); + } catch (userErr) { + console.error('Failed to fetch team users:', userErr); + } + + // Fetch recent audit logs (admin-only) + try { + if (currentUser?.role === 'admin') { + const auditResponse = await auditLogService.getLogs({ per_page: 3, page: 1 }); + setRecentAuditLogs(auditResponse?.logs || []); + } else { + setRecentAuditLogs([]); + } + } catch (auditErr) { + console.error('Failed to fetch audit logs:', auditErr); + } + setError(null); } catch (err) { console.error('Dashboard fetch error:', err); @@ -108,7 +134,7 @@ const AdminDashboard = () => { } finally { setLoading(false); } - }, [timeRange]); + }, [currentUser?.role, timeRange]); useEffect(() => { fetchDashboardData(); }, [fetchDashboardData]); @@ -141,13 +167,15 @@ const AdminDashboard = () => { }, [dashboardData]); return ( -
-
+
+
{/* ── Header ── */}
-

Admin Dashboard

+

+ {currentUser?.role === 'team_lead' ? 'Management Dashboard' : 'Admin Dashboard'} +

Overview of projects, tasks, and team progress

@@ -319,56 +347,112 @@ const AdminDashboard = () => {
- {[ - { label: 'To Do', value: dashboardData?.tasks?.todo || 0, color: 'bg-slate-500' }, - { label: 'In Progress', value: dashboardData?.tasks?.active || 0, color: 'bg-sky-500' }, - { label: 'In Review', value: dashboardData?.tasks?.inReview || 0, color: 'bg-amber-500' }, - { label: 'Done', value: dashboardData?.tasks?.done || 0, color: 'bg-emerald-500' }, - ].map(({ label, value, color }) => { - const total = (dashboardData?.tasks?.todo || 0) - + (dashboardData?.tasks?.active || 0) - + (dashboardData?.tasks?.inReview || 0) - + (dashboardData?.tasks?.done || 0); - const pct = total > 0 ? Math.round((value / total) * 100) : 0; - return ( -
-
- {label} - {value} ({pct}%) -
-
-
+ {(() => { + const backlog = dashboardData?.tasks?.backlog || 0; + const todo = dashboardData?.tasks?.todo || 0; + const inProgress = dashboardData?.tasks?.in_progress ?? dashboardData?.tasks?.active ?? 0; + const inReview = dashboardData?.tasks?.review ?? dashboardData?.tasks?.inReview ?? 0; + const done = dashboardData?.tasks?.done || 0; + const total = backlog + todo + inProgress + inReview + done; + + return [ + { label: 'Backlog', value: backlog, color: 'bg-orange-500' }, + { label: 'To Do', value: todo, color: 'bg-slate-500' }, + { label: 'In Progress', value: inProgress, color: 'bg-sky-500' }, + { label: 'In Review', value: inReview, color: 'bg-amber-500' }, + { label: 'Done', value: done, color: 'bg-emerald-500' }, + ].map(({ label, value, color }) => { + const pct = total > 0 ? Math.round((value / total) * 100) : 0; + + return ( +
+
+ {label} + {value} ({pct}%) +
+
+
+
-
- ); - })} + ); + }); + })()}
- {/* Admin Functions */} +
+ + {/* Team Overview Row */} +
- -
- {[ - { label: 'Manage Users', to: '/admin/users'}, - { label: 'All Tasks', to: '/tasks'}, - ].map(({ label, to, icon }) => ( - - {icon} - {label} - - - - - ))} -
+ + {teamUsers.length > 0 ? ( +
    + {teamUsers.map((user) => ( +
  • +
    +
    + {user.name?.charAt(0) || 'U'} +
    +
    +

    {user.name}

    +

    {user.role}

    +
    +
    +
    + Active +
    +
  • + ))} +
+ ) : ( +
+ No team members found. +
+ )}
+ {/* Reports/Stats Placeholder */} + {currentUser?.role === 'admin' && ( + + + {recentAuditLogs.length > 0 ? ( +
    + {recentAuditLogs.slice(0, 5).map(log => ( +
  • +
    +
    +
    + + + +
    +
    +
    +

    {log.action}

    +

    + {log.resource_type && `${log.resource_type}`} + {log.resource_type && log.resource_id && ` #${log.resource_id}`} +

    +

    + {new Date(log.created_at).toLocaleDateString()} {new Date(log.created_at).toLocaleTimeString()} +

    +
    +
    +
  • + ))} +
+ ) : ( +
+ No recent activity found. +
+ )} +
+ )}
)} diff --git a/frontend/src/pages/AdminProjectCreate.jsx b/frontend/src/pages/AdminProjectCreate.jsx index 56c908f..bac8256 100644 --- a/frontend/src/pages/AdminProjectCreate.jsx +++ b/frontend/src/pages/AdminProjectCreate.jsx @@ -69,9 +69,9 @@ const AdminProjectCreate = () => { }; return ( -
-
-
+
+
+

Create Project

Set up a new project and assign team members.

diff --git a/frontend/src/pages/AdminProjectEdit.jsx b/frontend/src/pages/AdminProjectEdit.jsx index b5f3312..02fce85 100644 --- a/frontend/src/pages/AdminProjectEdit.jsx +++ b/frontend/src/pages/AdminProjectEdit.jsx @@ -120,9 +120,9 @@ const AdminProjectEdit = () => { } return ( -
-
-
+
+
+

Edit Project

Update project details and team assignments.

diff --git a/frontend/src/pages/AdminProjects.jsx b/frontend/src/pages/AdminProjects.jsx index 597d1f2..96bb46e 100644 --- a/frontend/src/pages/AdminProjects.jsx +++ b/frontend/src/pages/AdminProjects.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import LoadingSpinner from '../components/LoadingSpinner'; +import { useAuth } from '../context/AuthContext'; import { projectService } from '../services/utils/api'; const statusClasses = { @@ -35,10 +36,12 @@ const formatDate = (value) => { }; const AdminProjects = () => { + const { currentUser } = useAuth(); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(''); + const dashboardPath = currentUser?.role === 'admin' ? '/admin' : '/BasicDashboard'; const loadProjects = async () => { try { @@ -81,9 +84,9 @@ const AdminProjects = () => { } return ( -
-
-
+
+
+

Projects

@@ -103,7 +106,7 @@ const AdminProjects = () => { Create Project Back to Dashboard diff --git a/frontend/src/pages/AdminSystemSettings.jsx b/frontend/src/pages/AdminSystemSettings.jsx new file mode 100644 index 0000000..344da86 --- /dev/null +++ b/frontend/src/pages/AdminSystemSettings.jsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react'; +import { settingsService } from '../services/utils/api'; + +const AdminSystemSettings = () => { + const [settings, setSettings] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [successMsg, setSuccessMsg] = useState(''); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const data = await settingsService.getSettings(); + setSettings(data); + } catch (err) { + setError('Failed to load settings'); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const handleNestedToggle = (parent, key) => { + setSettings((prev) => ({ + ...prev, + [parent]: { ...(prev[parent] || {}), [key]: !(prev[parent] || {})[key] }, + })); + }; + + const handleChange = (key, value) => { + setSettings((prev) => ({ ...prev, [key]: value })); + }; + + const handleSave = async () => { + setSaving(true); + setError(''); + try { + await settingsService.updateSettings(settings); + setSuccessMsg('Settings saved successfully'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err) { + setError(err.message || 'Failed to save settings'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ Loading settings... +
+ ); + } + + const notifSettings = settings.notification_settings || {}; + + const Toggle = ({ active, onToggle }) => ( + + ); + + return ( +
+
+
+

System Settings

+ + {error &&
{error}
} + {successMsg &&
{successMsg}
} + +
+
+ + +
+ +
+

Notification Settings

+
+ {[{ key: 'email_notifications', label: 'Email Notifications' }, + { key: 'task_assignments', label: 'Task Assignment Alerts' }, + { key: 'project_updates', label: 'Project Update Alerts' }].map(({ key, label }) => ( +
+ {label} + handleNestedToggle('notification_settings', key)} /> +
+ ))} +
+
+ +
+ +
+
+
+
+
+ ); +}; + +export default AdminSystemSettings; diff --git a/frontend/src/pages/AdminUsers.jsx b/frontend/src/pages/AdminUsers.jsx new file mode 100644 index 0000000..dcbb8cf --- /dev/null +++ b/frontend/src/pages/AdminUsers.jsx @@ -0,0 +1,379 @@ +import { useState, useEffect, useCallback } from 'react'; +import { adminUserService } from '../services/utils/api'; +import { useAuth } from '../context/AuthContext'; +import { ROLES } from '../utils/rbac'; + +const AdminUsers = () => { + const { currentUser, is } = useAuth(); + const isAdmin = is('admin'); + const [users, setUsers] = useState([]); + const [filteredUsers, setFilteredUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [roleFilter, setRoleFilter] = useState('all'); + const [editingUser, setEditingUser] = useState(null); + const [editForm, setEditForm] = useState({ name: '', email: '' }); + const [showCreateModal, setShowCreateModal] = useState(false); + const [createForm, setCreateForm] = useState({ name: '', email: '', password: '', role: 'developer' }); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [error, setError] = useState(''); + const [successMsg, setSuccessMsg] = useState(''); + + const fetchUsers = useCallback(async () => { + setLoading(true); + try { + const data = await adminUserService.getAllUsers(); + setUsers(data); + setFilteredUsers(data); + } catch (err) { + setError('Failed to fetch users'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + useEffect(() => { + let result = users; + if (searchTerm) { + const term = searchTerm.toLowerCase(); + result = result.filter( + (u) => u.name?.toLowerCase().includes(term) || u.email?.toLowerCase().includes(term) + ); + } + if (roleFilter !== 'all') { + result = result.filter((u) => u.role === roleFilter); + } + setFilteredUsers(result); + }, [searchTerm, roleFilter, users]); + + const handleRoleChange = async (userId, newRole) => { + if (!isAdmin) return; + try { + setError(''); + await adminUserService.updateUserRole(userId, newRole); + setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u))); + setSuccessMsg('Role updated successfully'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err) { + setError(err.message || 'Failed to update role'); + } + }; + + const handleCreateUser = async () => { + try { + setError(''); + const response = await adminUserService.createUser(createForm); + setUsers((prev) => [...prev, response.user]); + setShowCreateModal(false); + setCreateForm({ name: '', email: '', password: '', role: 'developer' }); + setSuccessMsg('User created successfully'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err) { + setError(err.message || 'Failed to create user'); + } + }; + + const handleEditSave = async () => { + if (!editingUser || !isAdmin) return; + try { + setError(''); + await adminUserService.updateUser(editingUser.id, editForm); + setUsers((prev) => + prev.map((u) => (u.id === editingUser.id ? { ...u, ...editForm } : u)) + ); + setEditingUser(null); + setSuccessMsg('User updated successfully'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err) { + setError(err.message || 'Failed to update user'); + } + }; + + const handleDelete = async (userId) => { + if (!isAdmin) return; + try { + setError(''); + await adminUserService.deleteUser(userId); + setUsers((prev) => prev.filter((u) => u.id !== userId)); + setDeleteConfirm(null); + setSuccessMsg('User deleted successfully'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err) { + setError(err.message || 'Failed to delete user'); + } + }; + + const roleBadge = (role) => { + const colors = { + admin: 'bg-rose-500/20 text-rose-300 border-rose-500/30', + team_lead: 'bg-amber-500/20 text-amber-300 border-amber-500/30', + developer: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30', + }; + return colors[role] || 'bg-slate-500/20 text-slate-300 border-slate-500/30'; + }; + + return ( +
+
+
+

User Management

+ {isAdmin && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + {successMsg && ( +
+ {successMsg} +
+ )} + + {/* Filters */} +
+ setSearchTerm(e.target.value)} + className="flex-1 rounded-lg border border-slate-700/60 bg-slate-900/80 py-2 px-3 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-rose-400/60" + /> + +
+ + {/* Users Table */} + {loading ? ( +
Loading users...
+ ) : ( +
+ + + + + + + + {isAdmin && } + + + + {filteredUsers.map((user) => ( + + + + + + {isAdmin && ( + + )} + + ))} + {filteredUsers.length === 0 && ( + + + + )} + +
IDNameEmailRoleActions
{user.id}{user.name}{user.email} + {!isAdmin || user.id === currentUser?.id ? ( + + {user.role} + + ) : ( + + )} + + + {user.id !== currentUser?.id && ( + + )} +
+ No users found. +
+
+ )} + + {/* Create Modal */} + {showCreateModal && ( +
+
+

Create New User

+
+
+ + setCreateForm({ ...createForm, name: e.target.value })} + className="w-full rounded-lg border border-slate-700/60 bg-slate-950/60 py-2 px-3 text-slate-100 focus:outline-none focus:ring-2 focus:ring-rose-400/60" + placeholder="Full Name" + /> +
+
+ + setCreateForm({ ...createForm, email: e.target.value })} + className="w-full rounded-lg border border-slate-700/60 bg-slate-950/60 py-2 px-3 text-slate-100 focus:outline-none focus:ring-2 focus:ring-rose-400/60" + placeholder="email@example.com" + /> +
+
+ + setCreateForm({ ...createForm, password: e.target.value })} + className="w-full rounded-lg border border-slate-700/60 bg-slate-950/60 py-2 px-3 text-slate-100 focus:outline-none focus:ring-2 focus:ring-rose-400/60" + placeholder="••••••••" + /> +
+
+ + +
+
+
+ + +
+
+
+ )} + + {/* Edit Modal */} + {editingUser && ( +
+
+

Edit User

+
+
+ + setEditForm({ ...editForm, name: e.target.value })} + className="w-full rounded-lg border border-slate-700/60 bg-slate-950/60 py-2 px-3 text-slate-100 focus:outline-none focus:ring-2 focus:ring-rose-400/60" + /> +
+
+ + setEditForm({ ...editForm, email: e.target.value })} + className="w-full rounded-lg border border-slate-700/60 bg-slate-950/60 py-2 px-3 text-slate-100 focus:outline-none focus:ring-2 focus:ring-rose-400/60" + /> +
+
+
+ + +
+
+
+ )} + + {/* Delete Confirmation Modal */} + {deleteConfirm && ( +
+
+

Confirm Delete

+

+ Are you sure you want to delete {deleteConfirm.name}? This action cannot be undone. +

+
+ + +
+
+
+ )} +
+
+ ); +}; + +export default AdminUsers; diff --git a/frontend/src/pages/BasicDashboard.jsx b/frontend/src/pages/BasicDashboard.jsx index 3d3c705..e57ff7c 100644 --- a/frontend/src/pages/BasicDashboard.jsx +++ b/frontend/src/pages/BasicDashboard.jsx @@ -1,27 +1,83 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { dashboardService } from '../services/utils/api'; -import TaskCard from '../components/TaskCard'; import LoadingSpinner from '../components/LoadingSpinner'; import { useAuth } from '../context/AuthContext'; -const panelClass = "bg-slate-900/70 border border-slate-800/70 rounded-2xl overflow-hidden shadow-[0_10px_30px_rgba(0,0,0,0.25)]"; -const panelHeaderClass = "px-4 py-5 sm:px-6 border-b border-slate-800 flex justify-between items-center"; +const panelClass = "bg-slate-900/70 border border-slate-800/70 rounded-2xl overflow-hidden shadow-md backdrop-blur-sm"; +const panelHeaderClass = "px-6 py-5 border-b border-slate-800/70 flex justify-between items-center"; const sectionTitleClass = "text-lg font-semibold text-slate-100"; +const getCount = (counts, keys, fallback = 0) => { + for (const key of keys) { + if (counts?.[key] !== undefined && counts?.[key] !== null) { + return counts[key]; + } + } + + return fallback; +}; + +const formatTaskDate = (dateValue) => { + if (!dateValue) return 'No deadline'; + + const date = new Date(dateValue); + if (Number.isNaN(date.getTime())) return 'No deadline'; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); +}; + +const getTaskDeadline = (task) => task?.deadline || task?.due_date || null; + +const getTaskStatusClass = (status) => { + const styles = { + todo: 'bg-slate-800/80 text-slate-300 border border-slate-700', + backlog: 'bg-slate-800/80 text-slate-300 border border-slate-700', + in_progress: 'bg-amber-500/15 text-amber-300 border border-amber-400/20', + review: 'bg-sky-500/15 text-sky-300 border border-sky-400/20', + done: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + completed: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + }; + + return styles[status] || 'bg-slate-800/80 text-slate-300 border border-slate-700'; +}; + +const getPriorityClass = (priority) => { + const styles = { + high: 'bg-rose-500/15 text-rose-300 border border-rose-400/20', + medium: 'bg-amber-500/15 text-amber-300 border border-amber-400/20', + low: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + }; + + return styles[priority] || 'bg-slate-800/80 text-slate-300 border border-slate-700'; +}; + +const getStatusLabel = (status) => { + const normalized = (status || 'unknown').replace(/[_-]/g, ' '); + return normalized.replace(/\b\w/g, (char) => char.toUpperCase()); +}; + const BasicDashboard = () => { const [dashboardData, setDashboardData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { currentUser } = useAuth(); + const { is, currentUser } = useAuth(); + const isTeamLead = is('team_lead'); + const isAdmin = is('admin'); + const dashboardTitle = isTeamLead ? 'Team Lead Workspace' : 'My Dashboard'; + const dashboardSubtitle = isTeamLead + ? 'Track your own work and keep an eye on the team without switching into the admin console.' + : 'View your tasks, projects, and GitHub activity'; const fetchDashboardData = useCallback(async () => { try { setLoading(true); - console.log("Fetching dashboard data with token:", JSON.stringify(currentUser?.token).substring(0, 20) + "..."); - console.log("Fetching dashboard stats..."); const data = await dashboardService.getBasicDashboardStats(); setDashboardData(data); + setError(null); } catch (err) { console.error("Dashboard fetch error:", err); @@ -29,7 +85,7 @@ const BasicDashboard = () => { } finally { setLoading(false); } - }, [currentUser]); + }, []); useEffect(() => { fetchDashboardData(); @@ -39,26 +95,39 @@ const BasicDashboard = () => { fetchDashboardData(); }; + // Use full tasks list if provided by API, otherwise fall back to recentTasks + // Limit to last 10 most recent tasks, sorted by most recent first + const orderedTasksToShow = ((dashboardData?.tasks && dashboardData.tasks.length) ? dashboardData.tasks : (dashboardData?.recentTasks || [])) + .sort((a, b) => { + const dateA = new Date(a.updated_at || a.created_at || 0).getTime(); + const dateB = new Date(b.updated_at || b.created_at || 0).getTime(); + return dateB - dateA; + }); + const tasksToShow = isTeamLead ? orderedTasksToShow : orderedTasksToShow.slice(0, 10); + return ( -
-
+
+
-

My Dashboard

-

- View your tasks, projects, and GitHub activity -

+

{dashboardTitle}

+

{dashboardSubtitle}

- +
+ + Create Task + + +
{loading ? ( @@ -82,11 +151,41 @@ const BasicDashboard = () => {
) : (
+ {(isTeamLead || isAdmin) && ( +
+
+
+

Management Snapshot

+

Keep the team moving without leaving this dashboard

+

+ {isAdmin + ? 'Admin users can manage users, reports, and progress from the admin console.' + : 'Team Leads can review progress, spot blockers, and create tasks from the same workspace.'} +

+
+ +
+ + Developer progress + + + Reports + + {currentUser?.role === 'admin' && ( + + Manage users + + )} +
+
+
+ )} + {/* Stats Summary */}
@@ -97,7 +196,7 @@ const BasicDashboard = () => { @@ -108,7 +207,7 @@ const BasicDashboard = () => { @@ -119,7 +218,7 @@ const BasicDashboard = () => { @@ -130,143 +229,78 @@ const BasicDashboard = () => {
{/* Main Content Grid */} -
- {/* My Tasks Section */} -
-
+
+
+
-

My Tasks

+
+

My Tasks

+

Your latest assigned work, with the same fields you see on the tasks page.

+
View all tasks
-
-
- In Progress - High Priority - Due Soon -
- - {/* Task Cards */} -
- {dashboardData?.recentTasks?.length > 0 ? ( - dashboardData.recentTasks.map((task) => ( - - )) - ) : ( -
- - - -

No tasks found

-

You don't have any tasks assigned yet.

-
- )} -
-
-
-
- - {/* Right Side Column */} -
- {/* GitHub Section */} -
-
-

GitHub Activity

-
- {!currentUser.github_connected ? ( -
- - - -

Connect GitHub

-

- Link your GitHub account to track contributions and sync tasks with issues. -

-
- - Connect Now - -
-
- ) : ( -
-
- {currentUser.github_username} { - e.target.onerror = null; - e.target.src = "https://avatars.githubusercontent.com/u/0"; - }} - /> -
-

- @{currentUser.github_username} -

-

Connected

-
-
- - {dashboardData?.githubActivity?.length > 0 ? ( -
    - {dashboardData.githubActivity.map((activity, index) => ( -
  • -
    -
    -

    - {activity.type === 'issue' ? ( - 🔍 - ) : activity.type === 'pull_request' ? ( - 🔀 - ) : ( - 📝 - )} - - {activity.title} - -

    -

    - {activity.repo} • {new Date(activity.date).toLocaleDateString()} -

    + {tasksToShow.length > 0 ? ( +
      + {tasksToShow.map((task) => ( +
    • + +
      +
      +
      +

      {task.title}

      + + {getStatusLabel(task.status)} + + + {getStatusLabel(task.priority)} Priority + +
      +

      + {task.description || 'No description provided'} +

      +
      + {task.project_name || 'No project'} + + Due {formatTaskDate(getTaskDeadline(task))} + + {task.progress || 0}% complete +
      +
      +
      -
    • - ))} -
    - ) : ( -
    -

    No recent GitHub activity

    -
    - )} - -
    - - View all activity - -
    + View +
    + +
  • + ))} +
+ ) : ( +
+ + + +

No tasks found

+

You don't have any tasks assigned yet.

)}
+
- {/* Projects Section */} -
+
+

My Projects

- {dashboardData?.projects?.length > 0 ? ( -
    + {dashboardData?.projects?.length > 0 ? ( +
      {dashboardData.projects.map((project) => (
    • @@ -275,14 +309,14 @@ const BasicDashboard = () => {
- {project.task_count} tasks + {project.task_count ?? 0} tasks - {project.completion_percentage}% complete + {project.completion_percentage ?? 0}% complete
@@ -296,26 +330,24 @@ const BasicDashboard = () => { )}
- {/* Upcoming Deadlines */} -
+

Upcoming Deadlines

- {dashboardData?.upcomingDeadlines?.length > 0 ? ( -
    +
      {dashboardData.upcomingDeadlines.map((task) => (
    • - -
      -

      {task.title}

      + +
      +
      +

      {task.title}

      +

      + Due {formatTaskDate(getTaskDeadline(task))} {task.project_name ? `• ${task.project_name}` : ''} +

      +
      -
      - - Due {formatDueDate(task.due_date)} - -
    • ))} @@ -326,6 +358,7 @@ const BasicDashboard = () => {
)}
+
@@ -338,41 +371,27 @@ const BasicDashboard = () => { // Helper Components const StatCard = ({ title, value, icon, color }) => { const colorClasses = { - primary: { - light: 'bg-sky-500/15 border-sky-400/20', - text: 'text-sky-300' - }, - success: { - light: 'bg-emerald-500/15 border-emerald-400/20', - text: 'text-emerald-300' - }, - warning: { - light: 'bg-amber-500/15 border-amber-400/20', - text: 'text-amber-300' - }, - error: { - light: 'bg-rose-500/15 border-rose-400/20', - text: 'text-rose-300' - } + blue: 'bg-sky-500/15 text-sky-300 border border-sky-400/20', + green: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', + yellow: 'bg-amber-500/15 text-amber-300 border border-amber-400/20', + red: 'bg-rose-500/15 text-rose-300 border border-rose-400/20', + purple: 'bg-purple-500/15 text-purple-300 border border-purple-400/20', }; - const classes = colorClasses[color] || colorClasses.primary; + const selectedColor = color === 'primary' ? 'blue' : + color === 'success' ? 'green' : + color === 'warning' ? 'yellow' : + color === 'error' ? 'red' : color; return ( -
-
-
-
-
{icon}
-
-
-
-
{title}
-
-
{value}
-
-
-
+
+
+
+ {icon} +
+
+

{title}

+

{value}

@@ -415,24 +434,4 @@ const TaskPriorityBadge = ({ priority = 'medium' }) => { ); }; -// Helper functions -const isOverdue = (dueDate) => { - return new Date(dueDate) < new Date(); -}; - -const formatDueDate = (dueDate) => { - const date = new Date(dueDate); - const today = new Date(); - const tomorrow = new Date(); - tomorrow.setDate(today.getDate() + 1); - - if (date.toDateString() === today.toDateString()) { - return 'Today'; - } else if (date.toDateString() === tomorrow.toDateString()) { - return 'Tomorrow'; - } else { - return date.toLocaleDateString(); - } -}; - export default BasicDashboard; \ No newline at end of file diff --git a/frontend/src/pages/DeveloperProgress.jsx b/frontend/src/pages/DeveloperProgress.jsx index f7d408d..56fa509 100644 --- a/frontend/src/pages/DeveloperProgress.jsx +++ b/frontend/src/pages/DeveloperProgress.jsx @@ -2,12 +2,15 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { dashboardService } from '../services/utils/api'; import LoadingSpinner from '../components/LoadingSpinner'; +import { useAuth } from '../context/AuthContext'; const DeveloperProgress = () => { const [developers, setDevelopers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState('all'); // all, active, completed + const { currentUser, is } = useAuth(); + const isTeamLead = is('team_lead'); useEffect(() => { const fetchDevelopersProgress = async () => { @@ -15,7 +18,7 @@ const DeveloperProgress = () => { setLoading(true); // Fetch developers with their tasks and progress stats - const developersData = await dashboardService.getDeveloperProgressStats(); + const developersData = await dashboardService.getDeveloperProgressStats({ currentUser }); setDevelopers(developersData || []); } catch (err) { @@ -27,7 +30,7 @@ const DeveloperProgress = () => { }; fetchDevelopersProgress(); - }, []); + }, [currentUser]); // Filter developers based on selected filter const filteredDevelopers = developers.filter(developer => { @@ -60,129 +63,125 @@ const DeveloperProgress = () => { } return ( -
-

Developer Progress

- - {/* Filter Controls */} -
- - - -
- - {filteredDevelopers.length === 0 ? ( -
- No developers match the selected filter +
+
+
+

Developer Progress

+

+ {isTeamLead ? 'Showing developers on your shared project teams.' : 'Showing all developers.'} +

- ) : ( -
- {filteredDevelopers.map(developer => ( -
- {/* Developer Header */} -
-

{developer.name}

-

{developer.role || 'Team Member'}

-
- - {/* Developer Stats */} -
-
-
-

Assigned Tasks

-

{developer.total_tasks || 0}

-
-
-

Completed

-

{developer.completed_tasks || 0}

-
+ + {/* Filter Controls */} +
+ + + +
+ + {filteredDevelopers.length === 0 ? ( +
+ No developers match the selected filter +
+ ) : ( +
+ {filteredDevelopers.map(developer => ( +
+ {/* Developer Header */} +
+

{developer.name}

+

{developer.role || 'Team Member'}

-
-
- Overall Progress - - {Math.round((developer.completed_tasks / (developer.total_tasks || 1)) * 100)}% - + {/* Developer Stats */} +
+
+
+

Total Tasks

+

{developer.total_tasks || 0}

+
+
+

Completed

+

{developer.completed_tasks || 0}

+
-
-
+ +
+
+ Overall Progress + + {Math.round((developer.completed_tasks / (developer.total_tasks || 1)) * 100)}% + +
+
+
+
-
- - {/* Recent Tasks */} - {developer.recent_tasks && developer.recent_tasks.length > 0 && ( - <> -

Recent Tasks

-
+ + {/* Recent Tasks */} + {developer.recent_tasks && developer.recent_tasks.length > 0 && ( +
+

Recent Activity

{developer.recent_tasks.slice(0, 3).map(task => ( -
- {task.title} - + {task.title} + {task.status.replace('_', ' ')}
-
-
= 100 ? 'bg-emerald-500' : - task.progress > 0 ? 'bg-rose-500' : 'bg-slate-700' - }`} - style={{ width: `${task.progress || 0}%` }} - >
-
))}
- - )} - - {/* View All Tasks Button */} -
- - View All Tasks - + )} + + {/* View All Tasks Button */} +
+ + View All Tasks + +
-
- ))} -
- )} + ))} +
+ )} +
); }; diff --git a/frontend/src/pages/Forbidden.jsx b/frontend/src/pages/Forbidden.jsx new file mode 100644 index 0000000..91f5162 --- /dev/null +++ b/frontend/src/pages/Forbidden.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +const Forbidden = () => { + const navigate = useNavigate(); + + return ( +
+
+
+ + + +

Access Denied

+

+ You don't have the necessary permissions to access this page or perform this action. +

+ +
+
+
+ ); +}; + +export default Forbidden; diff --git a/frontend/src/pages/GitHubIntegration.jsx b/frontend/src/pages/GitHubIntegration.jsx index 4478940..aa625a9 100644 --- a/frontend/src/pages/GitHubIntegration.jsx +++ b/frontend/src/pages/GitHubIntegration.jsx @@ -331,10 +331,10 @@ const GitHubIntegration = () => { } return ( -
-
-
-

GitHub Integration

+
+
+
+

GitHub Integration

{connectionStatus.rateLimitError ? renderRateLimitError() : connectionStatus.error && (
diff --git a/frontend/src/pages/GithubIntegrationDetail.jsx b/frontend/src/pages/GithubIntegrationDetail.jsx index 6bcefe4..e1142a4 100644 --- a/frontend/src/pages/GithubIntegrationDetail.jsx +++ b/frontend/src/pages/GithubIntegrationDetail.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; -import { githubService } from '../services/github'; -import { taskService } from '../services/utils/api'; +import { githubService, taskService } from '../services/utils/api'; import LoadingSpinner from '../components/LoadingSpinner'; const panelClass = "bg-slate-900/70 border border-slate-800/70 rounded-2xl p-6 shadow-[0_10px_30px_rgba(0,0,0,0.25)]"; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index a4ec012..bcff57a 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -61,7 +61,7 @@ const Login = () => { // Rest of the component remains unchanged return ( -
+

DevSync

diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 048dc40..d6c2f09 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -19,7 +19,6 @@ const Register = () => { email: "", password: "", confirmPassword: "", - role: "developer", }); const [formError, setFormError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -96,7 +95,6 @@ const Register = () => { email: "", password: "", confirmPassword: "", - role: "developer", }); // Redirect to login page after success @@ -112,7 +110,7 @@ const Register = () => { }; return ( -
+

DevSync

@@ -194,22 +192,6 @@ const Register = () => { />
-
- - -
-
{/* Report Details + Generated Reports */} -
-
+
+

Report Details

- +
+ +
-
+

Generated Reports

PDF downloads
-
+
{generatedReports.length === 0 ? (

No generated reports yet. Use Generate Report to create one. @@ -660,8 +731,19 @@ const Reports = () => { {generatedReports.map((report) => (

+ {/* Delete Button */} + +
{getReportLabel(report.type)} @@ -677,7 +759,7 @@ const Reports = () => { @@ -718,10 +800,10 @@ const Reports = () => { ); } - // ─── Main render ─────────────────────────────────────────────────────────── return ( -
-

Reports & Analytics

+
+
+

Reports & Analytics

{/* Controls */}
@@ -758,19 +840,38 @@ const Reports = () => {
-
- +
+
+ {reportType === 'github' && ( + {refreshing ? 'Refreshing GitHub stats...' : getGithubUpdatedLabel()} + )} +
+ +
+ {reportType === 'github' && ( + + )} + +
{renderCharts()} +
); }; -export default Reports; \ No newline at end of file +export default Reports; diff --git a/frontend/src/pages/TaskCreation.jsx b/frontend/src/pages/TaskCreation.jsx index cfaf036..bdbddf6 100644 --- a/frontend/src/pages/TaskCreation.jsx +++ b/frontend/src/pages/TaskCreation.jsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import TaskForm from "../components/TaskForm"; import { taskService } from "../services/utils/api"; import LoadingSpinner from "../components/LoadingSpinner"; +import { useAuth } from '../context/AuthContext'; const TaskCreation = () => { const navigate = useNavigate(); @@ -10,13 +11,20 @@ const TaskCreation = () => { const [users, setUsers] = useState([]); const [error, setError] = useState(null); const [projects, setProjects] = useState([]); + const { currentUser } = useAuth(); + + const canAssignTasks = currentUser?.role === 'team_lead' || currentUser?.role === 'admin'; useEffect(() => { // Fetch users and projects for the task assignment const fetchData = async () => { try { - const usersData = await taskService.getUsers(); - setUsers(usersData || []); + if (canAssignTasks) { + const usersData = await taskService.getUsers(); + setUsers(usersData || []); + } else if (currentUser) { + setUsers([currentUser]); + } const projectsData = await taskService.getProjects(); setProjects(projectsData || []); @@ -27,7 +35,7 @@ const TaskCreation = () => { }; fetchData(); - }, []); + }, [canAssignTasks, currentUser]); const handleSubmit = async (task) => { try { @@ -40,7 +48,7 @@ const TaskCreation = () => { description: task.description, status: task.status || "todo", priority: task.priority || "medium", - assigned_to: task.assignee, + assigned_to: canAssignTasks ? task.assignee : currentUser?.id, project_id: task.project, deadline: task.deadline ? new Date(task.deadline).toISOString() : null }; @@ -61,9 +69,9 @@ const TaskCreation = () => { }; return ( -
-
-

Create New Task

+
+
+

Create New Task

{error && (
@@ -81,6 +89,8 @@ const TaskCreation = () => { onSubmit={handleSubmit} users={users} projects={projects} + assigneeLocked={!canAssignTasks} + initialData={!canAssignTasks && currentUser ? { assigned_to: currentUser.id } : {}} />
)} diff --git a/frontend/src/pages/TaskDetailsUser.jsx b/frontend/src/pages/TaskDetailsUser.jsx index 87ebc68..5414427 100644 --- a/frontend/src/pages/TaskDetailsUser.jsx +++ b/frontend/src/pages/TaskDetailsUser.jsx @@ -23,7 +23,6 @@ function TaskDetailsUser() { const [githubLinks, setGithubLinks] = useState([]); const [loadingIssues, setLoadingIssues] = useState(false); const [loadingRepos, setLoadingRepos] = useState(false); - const [showGithubLink, setShowGithubLink] = useState(false); // Comments state const [comments, setComments] = useState([]); @@ -38,7 +37,10 @@ function TaskDetailsUser() { const [savingTaskEdit, setSavingTaskEdit] = useState(false); const [editError, setEditError] = useState(null); - const canEditTask = currentUser?.role === 'admin' || currentUser?.role === 'team_lead'; + const isManager = currentUser?.role === 'admin' || currentUser?.role === 'team_lead'; + const isAssigned = task?.assigned_to === currentUser?.id; + const canEditTask = isManager || isAssigned; + const canDeleteTask = isManager || isAssigned; useEffect(() => { const fetchTaskDetails = async () => { @@ -77,7 +79,6 @@ function TaskDetailsUser() { console.error('Failed to fetch repositories:', err); setRepositories([]); } finally { - setShowGithubLink(true); setLoadingRepos(false); } }; @@ -162,6 +163,24 @@ function TaskDetailsUser() { setEditError(null); }; + const handleDeleteTask = async () => { + const confirmDelete = window.confirm('Delete this task? This action cannot be undone.'); + if (!confirmDelete) { + return; + } + + try { + setUpdateLoading(true); + await taskService.deleteTask(id); + navigate('/tasks'); + } catch (err) { + console.error('Failed to delete task:', err); + alert('Failed to delete task. Please try again.'); + } finally { + setUpdateLoading(false); + } + }; + const handleTaskEditSubmit = async (editedTask) => { try { setSavingTaskEdit(true); @@ -211,7 +230,6 @@ function TaskDetailsUser() { }; const handleOpenGithubLink = async () => { - setShowGithubLink(true); if (repositories.length === 0) { setLoadingRepos(true); try { @@ -311,9 +329,9 @@ function TaskDetailsUser() { } return ( -
-
-
+
+
+
{/* Task Header */}
@@ -336,19 +354,31 @@ function TaskDetailsUser() { )}
- - {task.status === 'in_progress' ? 'In Progress' : - (task.status === 'completed' || task.status === 'done') ? 'Completed' : - task.status === 'todo' ? 'To Do' : - task.status === 'backlog' ? 'Backlog' : - task.status} - +
+ + {task.status === 'in_progress' ? 'In Progress' : + (task.status === 'completed' || task.status === 'done') ? 'Completed' : + task.status === 'todo' ? 'To Do' : + task.status === 'backlog' ? 'Backlog' : + task.status} + + {canDeleteTask && ( + + )} +
@@ -425,15 +455,15 @@ function TaskDetailsUser() {
- {/* Progress Update (if task is not completed) */} - {task.status !== 'completed' && task.status !== 'done' && ( + {/* Progress Update (if task is not completed and user can edit) */} + {task.status !== 'completed' && task.status !== 'done' && canEditTask && (

Update Progress

@@ -480,21 +510,20 @@ function TaskDetailsUser() { {/* Link new GitHub Issue */}
-

Link GitHub Issue:

- {repositories.length > 0 ? ( + {canEditTask && repositories.length > 0 ? ( <> -
+

Link GitHub Issue:

+
@@ -522,11 +551,21 @@ function TaskDetailsUser() {
- ) : ( + ) : canEditTask ? (
{loadingRepos ? (

Loading repositories...

- ) : !showGithubLink ? ( + ) : repositories.length === 0 ? ( + <> +

Connect your GitHub account to link issues

+ + Connect GitHub Account + + + ) : ( <>

Load your GitHub repositories to link issues

+ ) : ( +

Only the assignee can link GitHub items.

)}
diff --git a/frontend/src/pages/TaskList.jsx b/frontend/src/pages/TaskList.jsx index 33c9fc4..cbdd899 100644 --- a/frontend/src/pages/TaskList.jsx +++ b/frontend/src/pages/TaskList.jsx @@ -5,6 +5,8 @@ import { useAuth } from '../context/AuthContext'; import LoadingSpinner from '../components/LoadingSpinner'; const TaskList = () => { + const navigate = useNavigate(); + const { currentUser } = useAuth(); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [updating, setUpdating] = useState(false); @@ -12,22 +14,33 @@ const TaskList = () => { const [filters, setFilters] = useState({ status: 'all', priority: 'all', - search: '' + search: '', + scope: 'all' }); - const navigate = useNavigate(); - const { currentUser } = useAuth(); - const canCreateTasks = currentUser?.role === 'admin' || currentUser?.role === 'team_lead'; + const canCreateTasks = Boolean(currentUser); + + const urlParams = new URLSearchParams(window.location.search); + const requestedAssignee = urlParams.get('assigned_to') || urlParams.get('assignee'); - // Fetch tasks when component mounts + // Fetch tasks once; honor deep-link assignee query if provided useEffect(() => { + const initialUrlParams = new URLSearchParams(window.location.search); + const initialAssignee = initialUrlParams.get('assigned_to') || initialUrlParams.get('assignee'); + + if (initialAssignee) { + setFilters((prev) => ({ ...prev, scope: 'my' })); + fetchTasks({ assigned_to: initialAssignee }); + return; + } + fetchTasks(); }, []); - const fetchTasks = async () => { + const fetchTasks = async (params = null) => { try { setLoading(true); - const tasksData = await taskService.getAllTasks(); + const tasksData = await taskService.getAllTasks(params); setTasks(Array.isArray(tasksData) ? tasksData : []); setError(null); } catch (err) { @@ -86,11 +99,11 @@ const TaskList = () => { // Get status badge color and format status text const getStatusInfo = (status) => { const statusMap = { - 'todo': { class: 'bg-slate-800/70 text-slate-300', text: 'To Do' }, - 'backlog': { class: 'bg-slate-800/70 text-slate-300', text: 'Backlog' }, - 'in_progress': { class: 'bg-amber-500/15 text-amber-200', text: 'In Progress' }, - 'review': { class: 'bg-sky-500/15 text-sky-200', text: 'Review' }, - 'completed': { class: 'bg-emerald-500/15 text-emerald-200', text: 'Completed' } + 'todo': { class: 'text-slate-300', text: 'To Do' }, + 'backlog': { class: 'text-slate-300', text: 'Backlog' }, + 'in_progress': { class: 'text-amber-200', text: 'In Progress' }, + 'review': { class: 'text-sky-200', text: 'Review' }, + 'completed': { class: 'text-emerald-200', text: 'Completed' } }; return statusMap[status] || { class: 'bg-gray-100 text-gray-800', text: status }; @@ -99,9 +112,9 @@ const TaskList = () => { // Get priority badge const getPriorityBadge = (priority) => { const priorityMap = { - 'high': { class: 'bg-rose-500/15 text-rose-200', text: 'High', icon: '❗' }, - 'medium': { class: 'bg-amber-500/15 text-amber-200', text: 'Medium', icon: '⚠️' }, - 'low': { class: 'bg-sky-500/15 text-sky-200', text: 'Low', icon: '🔽' } + 'high': { class: 'bg-rose-500/15 text-rose-200', text: 'High'}, + 'medium': { class: 'bg-amber-500/15 text-amber-200', text: 'Medium' }, + 'low': { class: 'bg-sky-500/15 text-sky-200', text: 'Low' } }; return priorityMap[priority] || { class: 'bg-gray-100 text-gray-800', text: priority }; @@ -124,6 +137,18 @@ const TaskList = () => { return false; } + // Filter by scope (My Tasks vs All Tasks) + if (filters.scope === 'my') { + const targetAssigneeId = requestedAssignee ?? currentUser?.id; + if (targetAssigneeId === undefined || targetAssigneeId === null) { + return false; + } + + if (Number(task.assigned_to) !== Number(targetAssigneeId)) { + return false; + } + } + return true; }); @@ -136,11 +161,11 @@ const TaskList = () => { } return ( -
-
-
+
+
+
-

Your Tasks

+

Tasks

+ + {/* Scope Toggle for Developers */} +
+
+ + +
+
{/* Task List */} {filteredTasks.length === 0 ? ( @@ -245,7 +296,7 @@ const TaskList = () => {

No tasks found matching your filters

{filters.status !== 'all' || filters.priority !== 'all' || filters.search ? (