User commited on
Commit
01be06d
·
1 Parent(s): 33d40ba

feat: clean out authentication and authorization, rolemenu database fixed(use role id instead of rolecode now), adjust pages

Browse files
Files changed (30) hide show
  1. TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs +4 -3
  2. TaskTrackingSystem.Database/AppDbContextModels/RoleMenu.cs +3 -1
  3. TaskTrackingSystem.Shared/Models/Auth/AuthResponse.cs +2 -1
  4. TaskTrackingSystem.Shared/Models/Auth/ResetPasswordDto.cs +18 -0
  5. TaskTrackingSystem.WebApi/Features/Auth/AuthController.cs +13 -1
  6. TaskTrackingSystem.WebApi/Features/Auth/AuthService.cs +68 -13
  7. TaskTrackingSystem.WebApi/Features/Dashboard/DashboardController.cs +4 -3
  8. TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs +47 -15
  9. TaskTrackingSystem.WebApi/Features/Menu/MenuController.cs +14 -4
  10. TaskTrackingSystem.WebApi/Features/Menu/MenuService.cs +156 -18
  11. TaskTrackingSystem.WebApi/Features/Project/ProjectController.cs +14 -8
  12. TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs +46 -16
  13. TaskTrackingSystem.WebApi/Features/Role/RoleController.cs +1 -5
  14. TaskTrackingSystem.WebApi/Features/Role/RoleService.cs +73 -21
  15. TaskTrackingSystem.WebApi/Features/Task/TaskController.cs +9 -8
  16. TaskTrackingSystem.WebApi/Features/Task/TaskService.cs +59 -10
  17. TaskTrackingSystem.WebApi/Features/User/UserService.cs +10 -7
  18. TaskTrackingSystem.WebApi/Infrastructure/ClaimsPrincipalExtensions.cs +33 -0
  19. TaskTrackingSystem.WebApi/Program.cs +3 -1
  20. TaskTrackingSystem.WebApi/appsettings.json +4 -1
  21. TaskTrackingSystem.WebApp/AccountEndpoints.cs +56 -3
  22. TaskTrackingSystem.WebApp/Components/CustomAuthenticationStateProvider.cs +13 -0
  23. TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor +2 -2
  24. TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor +36 -218
  25. TaskTrackingSystem.WebApp/Components/Pages/Login.razor +13 -0
  26. TaskTrackingSystem.WebApp/Components/Pages/Projects.razor +1 -17
  27. TaskTrackingSystem.WebApp/Components/Pages/Reports.razor +0 -292
  28. TaskTrackingSystem.WebApp/Components/Pages/ResetPassword.razor +65 -0
  29. TaskTrackingSystem.WebApp/MenuAuthorizationService.cs +85 -46
  30. TaskTrackingSystem.WebApp/UserSessionState.cs +2 -2
TaskTrackingSystem.Database/AppDbContextModels/AppDbContext.cs CHANGED
@@ -42,8 +42,8 @@ public partial class AppDbContext : DbContext
42
  public virtual DbSet<User> Users { get; set; }
43
 
44
  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
45
- #warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263.
46
- => optionsBuilder.UseSqlServer("Server=.;Database=TTS;User Id=sa;Password=sasa@123;TrustServerCertificate=True;");
47
 
48
  protected override void OnModelCreating(ModelBuilder modelBuilder)
49
  {
@@ -184,8 +184,9 @@ public partial class AppDbContext : DbContext
184
  entity.Property(e => e.RoleMenuId).HasMaxLength(50);
185
  entity.Property(e => e.CreatedUserId).HasMaxLength(50);
186
  entity.Property(e => e.MenuCode).HasMaxLength(50);
187
- entity.Property(e => e.ModifiedUserId).HasMaxLength(50);
188
  entity.Property(e => e.RoleCode).HasMaxLength(50);
 
 
189
  });
190
 
191
  modelBuilder.Entity<Task>(entity =>
 
42
  public virtual DbSet<User> Users { get; set; }
43
 
44
  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
45
+ {
46
+ }
47
 
48
  protected override void OnModelCreating(ModelBuilder modelBuilder)
49
  {
 
184
  entity.Property(e => e.RoleMenuId).HasMaxLength(50);
185
  entity.Property(e => e.CreatedUserId).HasMaxLength(50);
186
  entity.Property(e => e.MenuCode).HasMaxLength(50);
 
187
  entity.Property(e => e.RoleCode).HasMaxLength(50);
188
+ entity.Property(e => e.ModifiedUserId).HasMaxLength(50);
189
+ entity.HasIndex(e => e.RoleId, "IX_RoleMenus_RoleId");
190
  });
191
 
192
  modelBuilder.Entity<Task>(entity =>
TaskTrackingSystem.Database/AppDbContextModels/RoleMenu.cs CHANGED
@@ -7,7 +7,9 @@ public partial class RoleMenu
7
  {
8
  public string RoleMenuId { get; set; } = null!;
9
 
10
- public string RoleCode { get; set; } = null!;
 
 
11
 
12
  public string MenuCode { get; set; } = null!;
13
 
 
7
  {
8
  public string RoleMenuId { get; set; } = null!;
9
 
10
+ public long RoleId { get; set; }
11
+
12
+ public string? RoleCode { get; set; }
13
 
14
  public string MenuCode { get; set; } = null!;
15
 
TaskTrackingSystem.Shared/Models/Auth/AuthResponse.cs CHANGED
@@ -11,6 +11,7 @@ namespace TaskTrackingSystem.Shared.Models.Auth
11
  public string Token { get; set; } = string.Empty;
12
  public string Username { get; set; } = string.Empty;
13
  public string Email { get; set; } = string.Empty;
 
14
  public string RoleName { get; set; } = string.Empty;
15
  }
16
- }
 
11
  public string Token { get; set; } = string.Empty;
12
  public string Username { get; set; } = string.Empty;
13
  public string Email { get; set; } = string.Empty;
14
+ public long RoleId { get; set; }
15
  public string RoleName { get; set; } = string.Empty;
16
  }
17
+ }
TaskTrackingSystem.Shared/Models/Auth/ResetPasswordDto.cs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System.ComponentModel.DataAnnotations;
2
+
3
+ namespace TaskTrackingSystem.Shared.Models.Auth
4
+ {
5
+ public class ResetPasswordDto
6
+ {
7
+ [Required]
8
+ public string UsernameOrEmail { get; set; } = string.Empty;
9
+
10
+ [Required]
11
+ public string RecoveryCode { get; set; } = string.Empty;
12
+
13
+ [Required]
14
+ [MinLength(8, ErrorMessage = ResultMessages.PasswordMinLengthRule)]
15
+ [RegularExpression(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z0-9]).+$", ErrorMessage = ResultMessages.PasswordComplexityRule)]
16
+ public string NewPassword { get; set; } = string.Empty;
17
+ }
18
+ }
TaskTrackingSystem.WebApi/Features/Auth/AuthController.cs CHANGED
@@ -52,5 +52,17 @@ namespace TaskTrackingSystem.WebApi.Features.Auth
52
  return BadRequest(Result<AuthResponseDto>.Failure(ex.Message, 400));
53
  }
54
  }
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
- }
 
52
  return BadRequest(Result<AuthResponseDto>.Failure(ex.Message, 400));
53
  }
54
  }
55
+
56
+ [HttpPost("reset-password")]
57
+ public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordDto resetPasswordDto)
58
+ {
59
+ var result = await _authService.ResetPasswordAsync(resetPasswordDto);
60
+ if (!result.IsSuccess)
61
+ {
62
+ return StatusCode(result.StatusCode, result);
63
+ }
64
+
65
+ return Ok(result);
66
+ }
67
  }
68
+ }
TaskTrackingSystem.WebApi/Features/Auth/AuthService.cs CHANGED
@@ -4,8 +4,10 @@ using System.Security.Claims;
4
  using System.Text;
5
  using System.Threading.Tasks;
6
  using Microsoft.EntityFrameworkCore;
 
7
  using Microsoft.Extensions.Configuration;
8
  using Microsoft.IdentityModel.Tokens;
 
9
  using TaskTrackingSystem.Shared.Models.Auth;
10
  using TaskTrackingSystem.Database;
11
  using TaskTrackingSystem.Database.AppDbContextModels;
@@ -16,11 +18,13 @@ namespace TaskTrackingSystem.WebApi.Features.Auth
16
  {
17
  private readonly AppDbContext _db;
18
  private readonly IConfiguration _configuration;
 
19
 
20
- public AuthService(AppDbContext db, IConfiguration configuration)
21
  {
22
  _db = db;
23
  _configuration = configuration;
 
24
  }
25
 
26
  public async Task<AuthResponseDto?> LoginAsync(LoginDto loginDto)
@@ -40,21 +44,34 @@ namespace TaskTrackingSystem.WebApi.Features.Auth
40
  }
41
 
42
 
43
- string inputHash = "HASHED_" + loginDto.Password;
44
- if (user.PasswordHash != inputHash)
45
  {
46
- return null;
 
 
 
 
 
 
47
  }
48
 
49
-
50
- var token = GenerateJwtToken(user.Id.ToString(), user.Username, user.Email, user.Role.Name);
 
 
 
 
 
 
51
 
52
  return new AuthResponseDto
53
  {
54
  Token = token,
55
  Username = user.Username,
56
  Email = user.Email,
57
- RoleName = user.Role.Name
 
58
  };
59
  }
60
 
@@ -80,36 +97,68 @@ namespace TaskTrackingSystem.WebApi.Features.Auth
80
  throw new InvalidOperationException("No system roles configured. Registration failed.");
81
  }
82
 
83
- string fakePasswordHash = "HASHED_" + dto.Password;
84
-
85
  var user = new TaskTrackingSystem.Database.AppDbContextModels.User
86
  {
87
  Username = dto.Username,
88
  FirstName = dto.FirstName,
89
  LastName = dto.LastName,
90
  Email = dto.Email,
91
- PasswordHash = fakePasswordHash,
92
  Phone = dto.Phone,
93
  RoleId = defaultRole.Id,
94
  IsActive = true,
95
  CreatedAt = DateTime.UtcNow
96
  };
97
 
 
 
98
  _db.Users.Add(user);
99
  await _db.SaveChangesAsync();
100
 
101
- var token = GenerateJwtToken(user.Id.ToString(), user.Username, user.Email, defaultRole.Name);
102
 
103
  return new AuthResponseDto
104
  {
105
  Token = token,
106
  Username = user.Username,
107
  Email = user.Email,
 
108
  RoleName = defaultRole.Name
109
  };
110
  }
111
 
112
- private string GenerateJwtToken(string userId, string username, string email, string roleName)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  {
114
  var jwtSettings = _configuration.GetSection("JwtSettings");
115
  var keyStr = jwtSettings["Key"] ?? "SUPER_SECRET_KEY_THAT_IS_LONG_ENOUGH_12345";
@@ -121,6 +170,7 @@ namespace TaskTrackingSystem.WebApi.Features.Auth
121
  new Claim(ClaimTypes.NameIdentifier, userId),
122
  new Claim(ClaimTypes.Name, username),
123
  new Claim(ClaimTypes.Email, email),
 
124
  new Claim(ClaimTypes.Role, roleName)
125
  };
126
 
@@ -134,5 +184,10 @@ namespace TaskTrackingSystem.WebApi.Features.Auth
134
 
135
  return new JwtSecurityTokenHandler().WriteToken(token);
136
  }
 
 
 
 
 
137
  }
138
- }
 
4
  using System.Text;
5
  using System.Threading.Tasks;
6
  using Microsoft.EntityFrameworkCore;
7
+ using Microsoft.AspNetCore.Identity;
8
  using Microsoft.Extensions.Configuration;
9
  using Microsoft.IdentityModel.Tokens;
10
+ using TaskTrackingSystem.Shared;
11
  using TaskTrackingSystem.Shared.Models.Auth;
12
  using TaskTrackingSystem.Database;
13
  using TaskTrackingSystem.Database.AppDbContextModels;
 
18
  {
19
  private readonly AppDbContext _db;
20
  private readonly IConfiguration _configuration;
21
+ private readonly IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User> _passwordHasher;
22
 
23
+ public AuthService(AppDbContext db, IConfiguration configuration, IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User> passwordHasher)
24
  {
25
  _db = db;
26
  _configuration = configuration;
27
+ _passwordHasher = passwordHasher;
28
  }
29
 
30
  public async Task<AuthResponseDto?> LoginAsync(LoginDto loginDto)
 
44
  }
45
 
46
 
47
+ var passwordCheck = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, loginDto.Password);
48
+ if (passwordCheck == PasswordVerificationResult.Failed)
49
  {
50
+ if (!IsLegacyPasswordMatch(user.PasswordHash, loginDto.Password))
51
+ {
52
+ return null;
53
+ }
54
+
55
+ user.PasswordHash = _passwordHasher.HashPassword(user, loginDto.Password);
56
+ await _db.SaveChangesAsync();
57
  }
58
 
59
+ var roleId = user.RoleId;
60
+ if (roleId <= 0 && user.Role != null)
61
+ {
62
+ roleId = user.Role.Id;
63
+ }
64
+
65
+ var roleName = user.Role?.Name ?? string.Empty;
66
+ var token = GenerateJwtToken(user.Id.ToString(), user.Username, user.Email, roleId, roleName);
67
 
68
  return new AuthResponseDto
69
  {
70
  Token = token,
71
  Username = user.Username,
72
  Email = user.Email,
73
+ RoleId = roleId,
74
+ RoleName = roleName
75
  };
76
  }
77
 
 
97
  throw new InvalidOperationException("No system roles configured. Registration failed.");
98
  }
99
 
 
 
100
  var user = new TaskTrackingSystem.Database.AppDbContextModels.User
101
  {
102
  Username = dto.Username,
103
  FirstName = dto.FirstName,
104
  LastName = dto.LastName,
105
  Email = dto.Email,
106
+ PasswordHash = string.Empty,
107
  Phone = dto.Phone,
108
  RoleId = defaultRole.Id,
109
  IsActive = true,
110
  CreatedAt = DateTime.UtcNow
111
  };
112
 
113
+ user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
114
+
115
  _db.Users.Add(user);
116
  await _db.SaveChangesAsync();
117
 
118
+ var token = GenerateJwtToken(user.Id.ToString(), user.Username, user.Email, defaultRole.Id, defaultRole.Name);
119
 
120
  return new AuthResponseDto
121
  {
122
  Token = token,
123
  Username = user.Username,
124
  Email = user.Email,
125
+ RoleId = defaultRole.Id,
126
  RoleName = defaultRole.Name
127
  };
128
  }
129
 
130
+ public async Task<Result> ResetPasswordAsync(ResetPasswordDto dto)
131
+ {
132
+ if (string.IsNullOrWhiteSpace(dto.UsernameOrEmail) ||
133
+ string.IsNullOrWhiteSpace(dto.RecoveryCode) ||
134
+ string.IsNullOrWhiteSpace(dto.NewPassword))
135
+ {
136
+ return Result.Failure(ResultMessages.FillAllFields, 400);
137
+ }
138
+
139
+ var expectedCode = _configuration["PasswordReset:RecoveryCode"] ?? "TASKIFY-RESET-2026";
140
+ if (!string.Equals(dto.RecoveryCode.Trim(), expectedCode, StringComparison.Ordinal))
141
+ {
142
+ return Result.Failure("Invalid recovery code.", 400);
143
+ }
144
+
145
+ var user = await _db.Users
146
+ .FirstOrDefaultAsync(u => (u.Username == dto.UsernameOrEmail || u.Email == dto.UsernameOrEmail) && !u.IsDeleted);
147
+
148
+ if (user == null)
149
+ {
150
+ return Result.Failure("Account not found.", 404);
151
+ }
152
+
153
+ user.PasswordHash = _passwordHasher.HashPassword(user, dto.NewPassword);
154
+ user.UpdatedAt = DateTime.UtcNow;
155
+ _db.Users.Update(user);
156
+ await _db.SaveChangesAsync();
157
+
158
+ return Result.Success(200);
159
+ }
160
+
161
+ private string GenerateJwtToken(string userId, string username, string email, long roleId, string roleName)
162
  {
163
  var jwtSettings = _configuration.GetSection("JwtSettings");
164
  var keyStr = jwtSettings["Key"] ?? "SUPER_SECRET_KEY_THAT_IS_LONG_ENOUGH_12345";
 
170
  new Claim(ClaimTypes.NameIdentifier, userId),
171
  new Claim(ClaimTypes.Name, username),
172
  new Claim(ClaimTypes.Email, email),
173
+ new Claim("role_id", roleId.ToString()),
174
  new Claim(ClaimTypes.Role, roleName)
175
  };
176
 
 
184
 
185
  return new JwtSecurityTokenHandler().WriteToken(token);
186
  }
187
+
188
+ private static bool IsLegacyPasswordMatch(string passwordHash, string password)
189
+ {
190
+ return string.Equals(passwordHash, "HASHED_" + password, StringComparison.Ordinal);
191
+ }
192
  }
193
+ }
TaskTrackingSystem.WebApi/Features/Dashboard/DashboardController.cs CHANGED
@@ -4,6 +4,7 @@ using System.Collections.Generic;
4
  using System.Threading.Tasks;
5
  using TaskTrackingSystem.Shared;
6
  using TaskTrackingSystem.Shared.Models.Dashboard;
 
7
 
8
  namespace TaskTrackingSystem.WebApi.Features.Dashboard
9
  {
@@ -22,21 +23,21 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
22
  [HttpGet("summary")]
23
  public async Task<ActionResult<Result<DashboardSummaryDto>>> GetSummary()
24
  {
25
- var result = await _dashboardService.GetSummaryAsync(true);
26
  return StatusCode(result.StatusCode, result);
27
  }
28
 
29
  [HttpGet("tasks-overview")]
30
  public async Task<ActionResult<Result<IEnumerable<TaskStatusOverviewDto>>>> GetTasksOverview()
31
  {
32
- var result = await _dashboardService.GetTasksOverviewAsync();
33
  return StatusCode(result.StatusCode, result);
34
  }
35
 
36
  [HttpGet("project-progress")]
37
  public async Task<ActionResult<Result<IEnumerable<ProjectProgressDto>>>> GetProjectProgress()
38
  {
39
- var result = await _dashboardService.GetProjectProgressAsync();
40
  return StatusCode(result.StatusCode, result);
41
  }
42
  }
 
4
  using System.Threading.Tasks;
5
  using TaskTrackingSystem.Shared;
6
  using TaskTrackingSystem.Shared.Models.Dashboard;
7
+ using TaskTrackingSystem.WebApi.Infrastructure;
8
 
9
  namespace TaskTrackingSystem.WebApi.Features.Dashboard
10
  {
 
23
  [HttpGet("summary")]
24
  public async Task<ActionResult<Result<DashboardSummaryDto>>> GetSummary()
25
  {
26
+ var result = await _dashboardService.GetSummaryAsync(User.GetRoleName(), User.GetUserId());
27
  return StatusCode(result.StatusCode, result);
28
  }
29
 
30
  [HttpGet("tasks-overview")]
31
  public async Task<ActionResult<Result<IEnumerable<TaskStatusOverviewDto>>>> GetTasksOverview()
32
  {
33
+ var result = await _dashboardService.GetTasksOverviewAsync(User.GetRoleName(), User.GetUserId());
34
  return StatusCode(result.StatusCode, result);
35
  }
36
 
37
  [HttpGet("project-progress")]
38
  public async Task<ActionResult<Result<IEnumerable<ProjectProgressDto>>>> GetProjectProgress()
39
  {
40
+ var result = await _dashboardService.GetProjectProgressAsync(User.GetRoleName(), User.GetUserId());
41
  return StatusCode(result.StatusCode, result);
42
  }
43
  }
TaskTrackingSystem.WebApi/Features/Dashboard/DashboardService.cs CHANGED
@@ -1,4 +1,5 @@
1
  using Microsoft.EntityFrameworkCore;
 
2
  using System.Collections.Generic;
3
  using System.Linq;
4
  using System.Threading.Tasks;
@@ -17,13 +18,18 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
17
  _db = db;
18
  }
19
 
20
- public async Task<Result<DashboardSummaryDto>> GetSummaryAsync(bool v)
21
  {
22
- var totalUsers = await _db.Users.CountAsync(u => true);
23
- var activeProjectsCount = await _db.Projects.CountAsync(p => p.IsDeleted != true);
24
- // Assumed PendingTasksCount = tasks where status is NOT Done (StatusId != 3, or we check common pending status logic)
25
- // Let's assume StatusId != 3 represents Pending tasks (e.g. 1 = To Do, 2 = In Progress, 3 = Done)
26
- var pendingTasksCount = await _db.Tasks.CountAsync(t => t.IsDeleted != true && t.StatusId != 3);
 
 
 
 
 
27
 
28
  var summary = new DashboardSummaryDto
29
  {
@@ -35,11 +41,9 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
35
  return Result<DashboardSummaryDto>.Success(summary);
36
  }
37
 
38
- public async Task<Result<IEnumerable<TaskStatusOverviewDto>>> GetTasksOverviewAsync()
39
  {
40
- // Group by StatusId
41
- var groupedTasks = await _db.Tasks
42
- .Where(t => t.IsDeleted != true)
43
  .GroupBy(t => t.StatusId)
44
  .Select(g => new
45
  {
@@ -80,10 +84,9 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
80
  return Result<IEnumerable<TaskStatusOverviewDto>>.Success(overview.OrderBy(o => o.StatusId));
81
  }
82
 
83
- public async Task<Result<IEnumerable<ProjectProgressDto>>> GetProjectProgressAsync()
84
  {
85
- var activeProjects = await _db.Projects
86
- .Where(p => p.IsDeleted != true)
87
  .ToListAsync();
88
 
89
  var progressList = new List<ProjectProgressDto>();
@@ -112,9 +115,38 @@ namespace TaskTrackingSystem.WebApi.Features.Dashboard
112
  return Result<IEnumerable<ProjectProgressDto>>.Success(progressList);
113
  }
114
 
115
- internal async System.Threading.Tasks.Task GetSummaryAsync(object value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  {
117
- throw new NotImplementedException();
 
118
  }
119
  }
120
  }
 
1
  using Microsoft.EntityFrameworkCore;
2
+ using System;
3
  using System.Collections.Generic;
4
  using System.Linq;
5
  using System.Threading.Tasks;
 
18
  _db = db;
19
  }
20
 
21
+ public async Task<Result<DashboardSummaryDto>> GetSummaryAsync(string roleName, long currentUserId)
22
  {
23
+ var elevated = IsElevated(roleName);
24
+ var projects = BuildAccessibleProjectQuery(roleName, currentUserId);
25
+ var tasks = BuildAccessibleTaskQuery(roleName, currentUserId);
26
+
27
+ var totalUsers = elevated
28
+ ? await _db.Users.CountAsync(u => !u.IsDeleted)
29
+ : await _db.Users.CountAsync(u => u.Id == currentUserId && !u.IsDeleted);
30
+
31
+ var activeProjectsCount = await projects.CountAsync();
32
+ var pendingTasksCount = await tasks.CountAsync(t => t.StatusId != 3);
33
 
34
  var summary = new DashboardSummaryDto
35
  {
 
41
  return Result<DashboardSummaryDto>.Success(summary);
42
  }
43
 
44
+ public async Task<Result<IEnumerable<TaskStatusOverviewDto>>> GetTasksOverviewAsync(string roleName, long currentUserId)
45
  {
46
+ var groupedTasks = await BuildAccessibleTaskQuery(roleName, currentUserId)
 
 
47
  .GroupBy(t => t.StatusId)
48
  .Select(g => new
49
  {
 
84
  return Result<IEnumerable<TaskStatusOverviewDto>>.Success(overview.OrderBy(o => o.StatusId));
85
  }
86
 
87
+ public async Task<Result<IEnumerable<ProjectProgressDto>>> GetProjectProgressAsync(string roleName, long currentUserId)
88
  {
89
+ var activeProjects = await BuildAccessibleProjectQuery(roleName, currentUserId)
 
90
  .ToListAsync();
91
 
92
  var progressList = new List<ProjectProgressDto>();
 
115
  return Result<IEnumerable<ProjectProgressDto>>.Success(progressList);
116
  }
117
 
118
+ private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Task> BuildAccessibleTaskQuery(string roleName, long currentUserId)
119
+ {
120
+ var query = _db.Tasks.Where(t => t.IsDeleted != true);
121
+
122
+ if (IsElevated(roleName))
123
+ {
124
+ return query;
125
+ }
126
+
127
+ return query.Where(t =>
128
+ t.AssignedTo == currentUserId ||
129
+ t.CreatedBy == currentUserId);
130
+ }
131
+
132
+ private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Project> BuildAccessibleProjectQuery(string roleName, long currentUserId)
133
+ {
134
+ var query = _db.Projects.Where(p => p.IsDeleted != true);
135
+
136
+ if (IsElevated(roleName))
137
+ {
138
+ return query;
139
+ }
140
+
141
+ return query.Where(p =>
142
+ p.CreatedById == currentUserId ||
143
+ p.ProjectMembers.Any(pm => pm.UserId == currentUserId));
144
+ }
145
+
146
+ private static bool IsElevated(string roleName)
147
  {
148
+ return roleName.Equals("Admin", StringComparison.OrdinalIgnoreCase) ||
149
+ roleName.Equals("Manager", StringComparison.OrdinalIgnoreCase);
150
  }
151
  }
152
  }
TaskTrackingSystem.WebApi/Features/Menu/MenuController.cs CHANGED
@@ -4,6 +4,7 @@ using System.Collections.Generic;
4
  using System.Security.Claims;
5
  using System.Threading.Tasks;
6
  using TaskTrackingSystem.Shared.Models.Menu;
 
7
 
8
  namespace TaskTrackingSystem.WebApi.Features.Menu
9
  {
@@ -22,15 +23,24 @@ namespace TaskTrackingSystem.WebApi.Features.Menu
22
  [HttpGet]
23
  public async Task<ActionResult<IEnumerable<MenuDto>>> GetMenus()
24
  {
25
- var roleToQuery = User.FindFirst(ClaimTypes.Role)?.Value;
26
- if (string.IsNullOrWhiteSpace(roleToQuery))
 
 
 
 
 
 
 
27
  {
28
  return Forbid();
29
  }
30
 
31
- var menus = await _menuService.GetMenusByRoleAsync(roleToQuery);
32
- return Ok(menus);
33
  }
 
 
34
  [HttpGet("all")]
35
  public async Task<ActionResult<IEnumerable<MenuAdminDto>>> GetAllMenus()
36
  {
 
4
  using System.Security.Claims;
5
  using System.Threading.Tasks;
6
  using TaskTrackingSystem.Shared.Models.Menu;
7
+ using TaskTrackingSystem.WebApi.Infrastructure;
8
 
9
  namespace TaskTrackingSystem.WebApi.Features.Menu
10
  {
 
23
  [HttpGet]
24
  public async Task<ActionResult<IEnumerable<MenuDto>>> GetMenus()
25
  {
26
+ var roleId = User.GetRoleId();
27
+ if (roleId > 0)
28
+ {
29
+ var roleMenus = await _menuService.GetMenusByRoleIdAsync(roleId);
30
+ return Ok(roleMenus);
31
+ }
32
+
33
+ var roleName = User.GetRoleName();
34
+ if (string.IsNullOrWhiteSpace(roleName))
35
  {
36
  return Forbid();
37
  }
38
 
39
+ var roleMenusByName = await _menuService.GetMenusByRoleNameAsync(roleName);
40
+ return Ok(roleMenusByName);
41
  }
42
+
43
+ [Authorize(Roles = "Admin")]
44
  [HttpGet("all")]
45
  public async Task<ActionResult<IEnumerable<MenuAdminDto>>> GetAllMenus()
46
  {
TaskTrackingSystem.WebApi/Features/Menu/MenuService.cs CHANGED
@@ -17,17 +17,43 @@ namespace TaskTrackingSystem.WebApi.Features.Menu
17
  _db = db;
18
  }
19
 
20
- public async Task<List<MenuDto>> GetMenusByRoleAsync(string roleName)
21
  {
22
- // 1. Get the MenuCodes configured for this Role
23
- var allowedMenuCodes = await _db.RoleMenus
24
- .Where(rm => rm.RoleCode == roleName && rm.DelFlag == 0)
25
- .Select(rm => rm.MenuCode)
26
- .ToListAsync();
27
 
28
- // 2. Retrieve the active menu items matching those codes
29
- var menus = await _db.MenuAdmins
30
- .Where(m => allowedMenuCodes.Contains(m.MenuCode) && m.Visible && m.DelFlag == 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  .OrderBy(m => m.OrderNo)
32
  .Select(m => new MenuDto
33
  {
@@ -40,19 +66,131 @@ namespace TaskTrackingSystem.WebApi.Features.Menu
40
  })
41
  .ToListAsync();
42
 
43
- // 3. Construct the menu hierarchy (Parents -> SubMenus)
44
- var parentMenus = menus.Where(m => string.IsNullOrEmpty(m.ParentCode) || m.ParentCode == "0").ToList();
45
- var childMenus = menus.Where(m => !string.IsNullOrEmpty(m.ParentCode) && m.ParentCode != "0").ToList();
46
 
47
- foreach (var parent in parentMenus)
48
  {
49
- parent.SubMenus = childMenus
50
- .Where(c => c.ParentCode == parent.MenuCode)
51
- .OrderBy(c => c.OrderNo)
52
- .ToList();
53
  }
54
 
55
- return parentMenus.OrderBy(p => p.OrderNo).ToList();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
  public async Task<List<MenuAdminDto>> GetAllMenusAsync()
58
  {
 
17
  _db = db;
18
  }
19
 
20
+ public async Task<List<MenuDto>> GetMenusByRoleIdAsync(long roleId)
21
  {
22
+ var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true);
23
+ if (role == null)
24
+ {
25
+ return new List<MenuDto>();
26
+ }
27
 
28
+ return await GetMenusForRoleAsync(role.Id, role.Name);
29
+ }
30
+
31
+ public async Task<List<MenuDto>> GetMenusByRoleNameAsync(string roleName)
32
+ {
33
+ if (string.IsNullOrWhiteSpace(roleName))
34
+ {
35
+ return new List<MenuDto>();
36
+ }
37
+
38
+ var role = await _db.Roles.FirstOrDefaultAsync(r => r.Name == roleName && r.IsDeleted != true);
39
+ if (role == null)
40
+ {
41
+ return new List<MenuDto>();
42
+ }
43
+
44
+ return await GetMenusForRoleAsync(role.Id, role.Name);
45
+ }
46
+
47
+ private async Task<List<MenuDto>> GetMenusForRoleAsync(long roleId, string roleName)
48
+ {
49
+ var role = await _db.Roles.FirstOrDefaultAsync(r => r.Id == roleId && r.IsDeleted != true);
50
+ if (role == null)
51
+ {
52
+ return new List<MenuDto>();
53
+ }
54
+
55
+ var allVisibleMenus = await _db.MenuAdmins
56
+ .Where(m => m.Visible && m.DelFlag == 0)
57
  .OrderBy(m => m.OrderNo)
58
  .Select(m => new MenuDto
59
  {
 
66
  })
67
  .ToListAsync();
68
 
69
+ // Keep compatibility with roles stored by either RoleId or the older RoleCode link.
70
+ var allowedMenuCodes = await GetAllowedMenuCodesAsync(role.Id, role.Name);
 
71
 
72
+ if (allowedMenuCodes.Count == 0)
73
  {
74
+ return new List<MenuDto>();
 
 
 
75
  }
76
 
77
+ var visibleMenuLookup = allVisibleMenus
78
+ .GroupBy(m => m.MenuCode, StringComparer.OrdinalIgnoreCase)
79
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
80
+
81
+ var expandedMenuCodes = ExpandWithAncestors(allowedMenuCodes, visibleMenuLookup);
82
+ var menus = allVisibleMenus
83
+ .Where(m => expandedMenuCodes.Contains(m.MenuCode))
84
+ .OrderBy(m => m.OrderNo)
85
+ .ToList();
86
+
87
+ return BuildHierarchy(menus);
88
+ }
89
+
90
+ private static List<MenuDto> BuildHierarchy(List<MenuDto> menus)
91
+ {
92
+ if (menus.Count == 0)
93
+ {
94
+ return new List<MenuDto>();
95
+ }
96
+
97
+ var menuLookup = menus
98
+ .GroupBy(m => m.MenuCode, StringComparer.OrdinalIgnoreCase)
99
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
100
+
101
+ foreach (var menu in menuLookup.Values)
102
+ {
103
+ menu.SubMenus = new List<MenuDto>();
104
+ }
105
+
106
+ var roots = new List<MenuDto>();
107
+
108
+ foreach (var menu in menuLookup.Values.OrderBy(m => m.OrderNo).ThenBy(m => m.MenuName))
109
+ {
110
+ var parentCode = NormalizeMenuCode(menu.ParentCode);
111
+ if (string.IsNullOrWhiteSpace(parentCode) ||
112
+ parentCode == "0" ||
113
+ !menuLookup.TryGetValue(parentCode, out var parent))
114
+ {
115
+ roots.Add(menu);
116
+ continue;
117
+ }
118
+
119
+ parent.SubMenus.Add(menu);
120
+ }
121
+
122
+ SortMenuTree(roots);
123
+ return roots;
124
+ }
125
+
126
+ private async Task<HashSet<string>> GetAllowedMenuCodesAsync(long roleId, string roleName)
127
+ {
128
+ var allowedMenuCodes = await _db.RoleMenus
129
+ .Where(rm => rm.RoleId == roleId && rm.DelFlag == 0)
130
+ .Select(rm => rm.MenuCode)
131
+ .ToListAsync();
132
+
133
+ if (allowedMenuCodes.Count > 0)
134
+ {
135
+ return allowedMenuCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
136
+ }
137
+
138
+ if (!string.IsNullOrWhiteSpace(roleName))
139
+ {
140
+ allowedMenuCodes = await _db.RoleMenus
141
+ .Where(rm => rm.RoleCode == roleName && rm.DelFlag == 0)
142
+ .Select(rm => rm.MenuCode)
143
+ .ToListAsync();
144
+ }
145
+
146
+ return allowedMenuCodes.ToHashSet(StringComparer.OrdinalIgnoreCase);
147
+ }
148
+
149
+ private static HashSet<string> ExpandWithAncestors(IEnumerable<string> menuCodes, IReadOnlyDictionary<string, MenuDto> menuLookup)
150
+ {
151
+ var expanded = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
152
+
153
+ foreach (var menuCode in menuCodes.Where(code => !string.IsNullOrWhiteSpace(code)))
154
+ {
155
+ var currentCode = menuCode.Trim();
156
+
157
+ while (!string.IsNullOrWhiteSpace(currentCode) && expanded.Add(currentCode))
158
+ {
159
+ if (!menuLookup.TryGetValue(currentCode, out var currentMenu))
160
+ {
161
+ break;
162
+ }
163
+
164
+ currentCode = NormalizeMenuCode(currentMenu.ParentCode);
165
+ if (string.IsNullOrWhiteSpace(currentCode) || currentCode == "0")
166
+ {
167
+ break;
168
+ }
169
+ }
170
+ }
171
+
172
+ return expanded;
173
+ }
174
+
175
+ private static void SortMenuTree(IList<MenuDto> menus)
176
+ {
177
+ var ordered = menus.OrderBy(m => m.OrderNo).ThenBy(m => m.MenuName, StringComparer.OrdinalIgnoreCase).ToList();
178
+ menus.Clear();
179
+
180
+ foreach (var menu in ordered)
181
+ {
182
+ if (menu.SubMenus.Count > 0)
183
+ {
184
+ SortMenuTree(menu.SubMenus);
185
+ }
186
+
187
+ menus.Add(menu);
188
+ }
189
+ }
190
+
191
+ private static string NormalizeMenuCode(string? code)
192
+ {
193
+ return string.IsNullOrWhiteSpace(code) ? string.Empty : code.Trim();
194
  }
195
  public async Task<List<MenuAdminDto>> GetAllMenusAsync()
196
  {
TaskTrackingSystem.WebApi/Features/Project/ProjectController.cs CHANGED
@@ -4,6 +4,7 @@ using TaskTrackingSystem.Shared;
4
  using TaskTrackingSystem.Shared.Models.User;
5
  using TaskTrackingSystem.Shared.Models.Task;
6
  using TaskTrackingSystem.Shared.Models.Project;
 
7
 
8
  namespace TaskTrackingSystem.WebApi.Features.Project
9
  {
@@ -22,14 +23,14 @@ namespace TaskTrackingSystem.WebApi.Features.Project
22
  [HttpGet]
23
  public async Task<ActionResult<IEnumerable<ProjectDto>>> GetProjects()
24
  {
25
- var projects = await _projectService.GetAllProjectsAsync();
26
  return Ok(projects);
27
  }
28
 
29
  [HttpGet("{id}")]
30
  public async Task<ActionResult<ProjectDto>> GetProject(long id)
31
  {
32
- var project = await _projectService.GetProjectByIdAsync(id);
33
  if (project == null)
34
  {
35
  return NotFound(new { message = $"Project with ID {id} not found." });
@@ -38,22 +39,25 @@ namespace TaskTrackingSystem.WebApi.Features.Project
38
  }
39
 
40
  [HttpPost]
 
41
  public async Task<ActionResult<Result<ProjectDto>>> CreateProject([FromBody] CreateProjectDto createProjectDto)
42
  {
43
- long? currentUserId = null;
44
  var result = await _projectService.CreateProjectAsync(createProjectDto, currentUserId);
45
  return StatusCode(result.StatusCode, result);
46
  }
47
 
48
  [HttpPut("{id}")]
 
49
  public async Task<ActionResult<Result>> UpdateProject(long id, [FromBody] UpdateProjectDto updateProjectDto)
50
  {
51
- long? currentUserId = null;
52
  var result = await _projectService.UpdateProjectAsync(id, updateProjectDto, currentUserId);
53
  return StatusCode(result.StatusCode, result);
54
  }
55
 
56
  [HttpDelete("{id}")]
 
57
  public async Task<ActionResult<Result>> DeleteProject(long id)
58
  {
59
  var result = await _projectService.SoftDeleteProjectAsync(id);
@@ -63,14 +67,15 @@ namespace TaskTrackingSystem.WebApi.Features.Project
63
  [HttpGet("{id}/members")]
64
  public async Task<ActionResult<Result<IEnumerable<UserDto>>>> GetProjectMembers(long id)
65
  {
66
- var result = await _projectService.GetProjectMembersAsync(id);
67
  return StatusCode(result.StatusCode, result);
68
  }
69
 
70
  [HttpPost("{id}/members")]
 
71
  public async Task<IActionResult> AssignProjectMembers(long id, [FromBody] AssignMembersDto dto)
72
  {
73
- var result = await _projectService.AssignMembersToProjectAsync(id, dto);
74
  if (!result.IsSuccess)
75
  {
76
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
@@ -79,9 +84,10 @@ namespace TaskTrackingSystem.WebApi.Features.Project
79
  }
80
 
81
  [HttpDelete("{id}/members/{userId}")]
 
82
  public async Task<IActionResult> RemoveProjectMember(long id, long userId)
83
  {
84
- var result = await _projectService.RemoveMemberFromProjectAsync(id, userId);
85
  if (!result.IsSuccess)
86
  {
87
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
@@ -92,7 +98,7 @@ namespace TaskTrackingSystem.WebApi.Features.Project
92
  [HttpGet("{id}/tasks")]
93
  public async Task<ActionResult<Result<IEnumerable<TaskDto>>>> GetProjectTasks(long id)
94
  {
95
- var result = await _projectService.GetProjectTasksAsync(id);
96
  return StatusCode(result.StatusCode, result);
97
  }
98
  }
 
4
  using TaskTrackingSystem.Shared.Models.User;
5
  using TaskTrackingSystem.Shared.Models.Task;
6
  using TaskTrackingSystem.Shared.Models.Project;
7
+ using TaskTrackingSystem.WebApi.Infrastructure;
8
 
9
  namespace TaskTrackingSystem.WebApi.Features.Project
10
  {
 
23
  [HttpGet]
24
  public async Task<ActionResult<IEnumerable<ProjectDto>>> GetProjects()
25
  {
26
+ var projects = await _projectService.GetAllProjectsAsync(User.GetRoleName(), User.GetUserId());
27
  return Ok(projects);
28
  }
29
 
30
  [HttpGet("{id}")]
31
  public async Task<ActionResult<ProjectDto>> GetProject(long id)
32
  {
33
+ var project = await _projectService.GetProjectByIdAsync(id, User.GetRoleName(), User.GetUserId());
34
  if (project == null)
35
  {
36
  return NotFound(new { message = $"Project with ID {id} not found." });
 
39
  }
40
 
41
  [HttpPost]
42
+ [Authorize(Roles = "Admin")]
43
  public async Task<ActionResult<Result<ProjectDto>>> CreateProject([FromBody] CreateProjectDto createProjectDto)
44
  {
45
+ long? currentUserId = User.GetUserId();
46
  var result = await _projectService.CreateProjectAsync(createProjectDto, currentUserId);
47
  return StatusCode(result.StatusCode, result);
48
  }
49
 
50
  [HttpPut("{id}")]
51
+ [Authorize(Roles = "Admin")]
52
  public async Task<ActionResult<Result>> UpdateProject(long id, [FromBody] UpdateProjectDto updateProjectDto)
53
  {
54
+ long? currentUserId = User.GetUserId();
55
  var result = await _projectService.UpdateProjectAsync(id, updateProjectDto, currentUserId);
56
  return StatusCode(result.StatusCode, result);
57
  }
58
 
59
  [HttpDelete("{id}")]
60
+ [Authorize(Roles = "Admin")]
61
  public async Task<ActionResult<Result>> DeleteProject(long id)
62
  {
63
  var result = await _projectService.SoftDeleteProjectAsync(id);
 
67
  [HttpGet("{id}/members")]
68
  public async Task<ActionResult<Result<IEnumerable<UserDto>>>> GetProjectMembers(long id)
69
  {
70
+ var result = await _projectService.GetProjectMembersAsync(id, User.GetRoleName(), User.GetUserId());
71
  return StatusCode(result.StatusCode, result);
72
  }
73
 
74
  [HttpPost("{id}/members")]
75
+ [Authorize(Roles = "Admin")]
76
  public async Task<IActionResult> AssignProjectMembers(long id, [FromBody] AssignMembersDto dto)
77
  {
78
+ var result = await _projectService.AssignMembersToProjectAsync(id, dto, User.GetUserId());
79
  if (!result.IsSuccess)
80
  {
81
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
 
84
  }
85
 
86
  [HttpDelete("{id}/members/{userId}")]
87
+ [Authorize(Roles = "Admin")]
88
  public async Task<IActionResult> RemoveProjectMember(long id, long userId)
89
  {
90
+ var result = await _projectService.RemoveMemberFromProjectAsync(id, userId, User.GetUserId());
91
  if (!result.IsSuccess)
92
  {
93
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
 
98
  [HttpGet("{id}/tasks")]
99
  public async Task<ActionResult<Result<IEnumerable<TaskDto>>>> GetProjectTasks(long id)
100
  {
101
+ var result = await _projectService.GetProjectTasksAsync(id, User.GetRoleName(), User.GetUserId());
102
  return StatusCode(result.StatusCode, result);
103
  }
104
  }
TaskTrackingSystem.WebApi/Features/Project/ProjectService.cs CHANGED
@@ -20,10 +20,9 @@ namespace TaskTrackingSystem.WebApi.Features.Project
20
  _db = db;
21
  }
22
 
23
- public async Task<IEnumerable<ProjectDto>> GetAllProjectsAsync()
24
  {
25
- return await _db.Projects
26
- .Where(p => p.IsDeleted != true)
27
  .OrderBy(p => p.EndDate)
28
  .ThenByDescending(p => p.CreatedAt ?? DateTime.UtcNow)
29
  .Select(p => new ProjectDto
@@ -39,10 +38,10 @@ namespace TaskTrackingSystem.WebApi.Features.Project
39
  }).ToListAsync();
40
  }
41
 
42
- public async Task<ProjectDto?> GetProjectByIdAsync(long id)
43
  {
44
- var project = await _db.Projects
45
- .FirstOrDefaultAsync(p => p.Id == id && p.IsDeleted != true);
46
 
47
  if (project == null) return null;
48
 
@@ -131,12 +130,11 @@ namespace TaskTrackingSystem.WebApi.Features.Project
131
  return Result.Success(200);
132
  }
133
 
134
- public async Task<Result<IEnumerable<UserDto>>> GetProjectMembersAsync(long projectId)
135
  {
136
- var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true);
137
- if (!projectExists)
138
  {
139
- return Result<IEnumerable<UserDto>>.Failure(ResultMessages.ProjectNotFound(projectId), 404);
140
  }
141
 
142
  var members = await _db.ProjectMembers
@@ -161,7 +159,7 @@ namespace TaskTrackingSystem.WebApi.Features.Project
161
  return Result<IEnumerable<UserDto>>.Success(members);
162
  }
163
 
164
- public async Task<Result> AssignMembersToProjectAsync(long projectId, AssignMembersDto dto)
165
  {
166
  var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true);
167
  if (!projectExists)
@@ -214,7 +212,7 @@ namespace TaskTrackingSystem.WebApi.Features.Project
214
  return Result.Success(200);
215
  }
216
 
217
- public async Task<Result> RemoveMemberFromProjectAsync(long projectId, long userId)
218
  {
219
  var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true);
220
  if (!projectExists)
@@ -236,12 +234,11 @@ namespace TaskTrackingSystem.WebApi.Features.Project
236
  return Result.Success(204); // No Content success
237
  }
238
 
239
- public async Task<Result<IEnumerable<TaskDto>>> GetProjectTasksAsync(long projectId)
240
  {
241
- var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true);
242
- if (!projectExists)
243
  {
244
- return Result<IEnumerable<TaskDto>>.Failure(ResultMessages.ProjectNotFound(projectId), 404);
245
  }
246
 
247
  var tasks = await _db.Tasks
@@ -269,5 +266,38 @@ namespace TaskTrackingSystem.WebApi.Features.Project
269
 
270
  return Result<IEnumerable<TaskDto>>.Success(tasks);
271
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  }
273
  }
 
20
  _db = db;
21
  }
22
 
23
+ public async Task<IEnumerable<ProjectDto>> GetAllProjectsAsync(string roleName, long currentUserId)
24
  {
25
+ return await BuildAccessibleProjectQuery(roleName, currentUserId)
 
26
  .OrderBy(p => p.EndDate)
27
  .ThenByDescending(p => p.CreatedAt ?? DateTime.UtcNow)
28
  .Select(p => new ProjectDto
 
38
  }).ToListAsync();
39
  }
40
 
41
+ public async Task<ProjectDto?> GetProjectByIdAsync(long id, string roleName, long currentUserId)
42
  {
43
+ var project = await BuildAccessibleProjectQuery(roleName, currentUserId)
44
+ .FirstOrDefaultAsync(p => p.Id == id);
45
 
46
  if (project == null) return null;
47
 
 
130
  return Result.Success(200);
131
  }
132
 
133
+ public async Task<Result<IEnumerable<UserDto>>> GetProjectMembersAsync(long projectId, string roleName, long currentUserId)
134
  {
135
+ if (!await IsProjectAccessibleAsync(projectId, roleName, currentUserId))
 
136
  {
137
+ return Result<IEnumerable<UserDto>>.Failure("You do not have access to this project.", 403);
138
  }
139
 
140
  var members = await _db.ProjectMembers
 
159
  return Result<IEnumerable<UserDto>>.Success(members);
160
  }
161
 
162
+ public async Task<Result> AssignMembersToProjectAsync(long projectId, AssignMembersDto dto, long? currentUserId = null)
163
  {
164
  var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true);
165
  if (!projectExists)
 
212
  return Result.Success(200);
213
  }
214
 
215
+ public async Task<Result> RemoveMemberFromProjectAsync(long projectId, long userId, long? currentUserId = null)
216
  {
217
  var projectExists = await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true);
218
  if (!projectExists)
 
234
  return Result.Success(204); // No Content success
235
  }
236
 
237
+ public async Task<Result<IEnumerable<TaskDto>>> GetProjectTasksAsync(long projectId, string roleName, long currentUserId)
238
  {
239
+ if (!await IsProjectAccessibleAsync(projectId, roleName, currentUserId))
 
240
  {
241
+ return Result<IEnumerable<TaskDto>>.Failure("You do not have access to this project.", 403);
242
  }
243
 
244
  var tasks = await _db.Tasks
 
266
 
267
  return Result<IEnumerable<TaskDto>>.Success(tasks);
268
  }
269
+
270
+ private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Project> BuildAccessibleProjectQuery(string roleName, long currentUserId)
271
+ {
272
+ var query = _db.Projects.Where(p => p.IsDeleted != true);
273
+
274
+ if (IsElevated(roleName))
275
+ {
276
+ return query;
277
+ }
278
+
279
+ return query.Where(p =>
280
+ p.CreatedById == currentUserId ||
281
+ p.ProjectMembers.Any(pm => pm.UserId == currentUserId));
282
+ }
283
+
284
+ private async Task<bool> IsProjectAccessibleAsync(long projectId, string roleName, long currentUserId)
285
+ {
286
+ if (IsElevated(roleName))
287
+ {
288
+ return await _db.Projects.AnyAsync(p => p.Id == projectId && p.IsDeleted != true);
289
+ }
290
+
291
+ return await _db.Projects.AnyAsync(p =>
292
+ p.Id == projectId &&
293
+ p.IsDeleted != true &&
294
+ (p.CreatedById == currentUserId || p.ProjectMembers.Any(pm => pm.UserId == currentUserId)));
295
+ }
296
+
297
+ private static bool IsElevated(string roleName)
298
+ {
299
+ return roleName.Equals("Admin", StringComparison.OrdinalIgnoreCase) ||
300
+ roleName.Equals("Manager", StringComparison.OrdinalIgnoreCase);
301
+ }
302
  }
303
  }
TaskTrackingSystem.WebApi/Features/Role/RoleController.cs CHANGED
@@ -7,7 +7,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
7
  {
8
  [Route("api/[controller]")]
9
  [ApiController]
10
- [Authorize]
11
  public class RoleController : ControllerBase
12
  {
13
  private readonly RoleService _roleService;
@@ -36,7 +36,6 @@ namespace TaskTrackingSystem.WebApi.Features.Role
36
  }
37
 
38
  [HttpPost]
39
- [Authorize(Roles = "Admin")]
40
  public async Task<ActionResult<Result<RoleDto>>> CreateRole([FromBody] CreateRoleDto createRoleDto)
41
  {
42
  long? currentUserId = null;
@@ -45,7 +44,6 @@ namespace TaskTrackingSystem.WebApi.Features.Role
45
  }
46
 
47
  [HttpPut("{id}")]
48
- [Authorize(Roles = "Admin")]
49
  public async Task<ActionResult<Result>> UpdateRole(long id, [FromBody] UpdateRoleDto updateRoleDto)
50
  {
51
  long? currentUserId = null;
@@ -54,7 +52,6 @@ namespace TaskTrackingSystem.WebApi.Features.Role
54
  }
55
 
56
  [HttpDelete("{id}")]
57
- [Authorize(Roles = "Admin")]
58
  public async Task<ActionResult<Result>> DeleteRole(long id)
59
  {
60
  var result = await _roleService.SoftDeleteRoleAsync(id);
@@ -63,7 +60,6 @@ namespace TaskTrackingSystem.WebApi.Features.Role
63
 
64
 
65
  [HttpGet("{id}/menus")]
66
- [Authorize(Roles = "Admin")]
67
  public async Task<ActionResult<Result<List<string>>>> GetAssignedMenus(long id)
68
  {
69
  var result = await _roleService.GetAssignedMenusByRoleIdAsync(id);
 
7
  {
8
  [Route("api/[controller]")]
9
  [ApiController]
10
+ [Authorize(Roles = "Admin")]
11
  public class RoleController : ControllerBase
12
  {
13
  private readonly RoleService _roleService;
 
36
  }
37
 
38
  [HttpPost]
 
39
  public async Task<ActionResult<Result<RoleDto>>> CreateRole([FromBody] CreateRoleDto createRoleDto)
40
  {
41
  long? currentUserId = null;
 
44
  }
45
 
46
  [HttpPut("{id}")]
 
47
  public async Task<ActionResult<Result>> UpdateRole(long id, [FromBody] UpdateRoleDto updateRoleDto)
48
  {
49
  long? currentUserId = null;
 
52
  }
53
 
54
  [HttpDelete("{id}")]
 
55
  public async Task<ActionResult<Result>> DeleteRole(long id)
56
  {
57
  var result = await _roleService.SoftDeleteRoleAsync(id);
 
60
 
61
 
62
  [HttpGet("{id}/menus")]
 
63
  public async Task<ActionResult<Result<List<string>>>> GetAssignedMenus(long id)
64
  {
65
  var result = await _roleService.GetAssignedMenusByRoleIdAsync(id);
TaskTrackingSystem.WebApi/Features/Role/RoleService.cs CHANGED
@@ -27,7 +27,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
27
  Id = r.Id,
28
  Name = r.Name,
29
  Description = r.Description,
30
- CreatedAt = (DateTime)r.CreatedAt
31
  }).ToListAsync();
32
  }
33
 
@@ -43,7 +43,7 @@ namespace TaskTrackingSystem.WebApi.Features.Role
43
  Id = role.Id,
44
  Name = role.Name,
45
  Description = role.Description,
46
- CreatedAt = (DateTime)role.CreatedAt
47
  };
48
  }
49
 
@@ -60,6 +60,15 @@ namespace TaskTrackingSystem.WebApi.Features.Role
60
  return Result<RoleDto>.Failure("Role name is already taken.", 400);
61
  }
62
 
 
 
 
 
 
 
 
 
 
63
  var role = new TaskTrackingSystem.Database.AppDbContextModels.Role
64
  {
65
  Name = dto.Name,
@@ -71,22 +80,10 @@ namespace TaskTrackingSystem.WebApi.Features.Role
71
  _db.Roles.Add(role);
72
  await _db.SaveChangesAsync();
73
 
74
- // Assign menus and actions if provided
75
- if (dto.MenuCodes != null && dto.MenuCodes.Any())
76
  {
77
- int i = 1;
78
- foreach (var menuCode in dto.MenuCodes)
79
- {
80
- _db.RoleMenus.Add(new RoleMenu
81
- {
82
- RoleMenuId = $"RM_{role.Name}_{i++}_{DateTime.UtcNow.Ticks}",
83
- RoleCode = role.Name,
84
- MenuCode = menuCode,
85
- DelFlag = 0,
86
- CreatedDateTime = DateTime.UtcNow
87
- });
88
- }
89
- await _db.SaveChangesAsync();
90
  }
91
 
92
  var resultDto = new RoleDto
@@ -116,11 +113,21 @@ namespace TaskTrackingSystem.WebApi.Features.Role
116
  return Result.Failure("Role name is already taken by another role.", 400);
117
  }
118
 
 
119
  role.Name = dto.Name;
120
  role.Description = dto.Description;
121
  role.UpdatedAt = DateTime.UtcNow;
122
  role.UpdatedBy = currentUserId;
123
 
 
 
 
 
 
 
 
 
 
124
  _db.Roles.Update(role);
125
  await _db.SaveChangesAsync();
126
  return Result.Success(200);
@@ -147,10 +154,18 @@ namespace TaskTrackingSystem.WebApi.Features.Role
147
  }
148
 
149
  var menuCodes = await _db.RoleMenus
150
- .Where(rm => rm.RoleCode == role.Name && rm.DelFlag == 0)
151
  .Select(rm => rm.MenuCode)
152
  .ToListAsync();
153
 
 
 
 
 
 
 
 
 
154
  return Result<List<string>>.Success(menuCodes);
155
  }
156
 
@@ -167,9 +182,15 @@ namespace TaskTrackingSystem.WebApi.Features.Role
167
  return Result.Failure("MenuCodes cannot be null", 400);
168
  }
169
 
170
- // Remove existing role menus matching RoleCode
 
 
 
 
 
 
171
  var existingRoleMenus = await _db.RoleMenus
172
- .Where(rm => rm.RoleCode == role.Name)
173
  .ToListAsync();
174
 
175
  if (existingRoleMenus.Any())
@@ -183,7 +204,8 @@ namespace TaskTrackingSystem.WebApi.Features.Role
183
  {
184
  _db.RoleMenus.Add(new RoleMenu
185
  {
186
- RoleMenuId = $"RM_{role.Name}_{i++}_{DateTime.UtcNow.Ticks}",
 
187
  RoleCode = role.Name,
188
  MenuCode = menuCode,
189
  DelFlag = 0,
@@ -194,5 +216,35 @@ namespace TaskTrackingSystem.WebApi.Features.Role
194
  await _db.SaveChangesAsync();
195
  return Result.Success(200);
196
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  }
198
  }
 
27
  Id = r.Id,
28
  Name = r.Name,
29
  Description = r.Description,
30
+ CreatedAt = r.CreatedAt ?? DateTime.UtcNow
31
  }).ToListAsync();
32
  }
33
 
 
43
  Id = role.Id,
44
  Name = role.Name,
45
  Description = role.Description,
46
+ CreatedAt = role.CreatedAt ?? DateTime.UtcNow
47
  };
48
  }
49
 
 
60
  return Result<RoleDto>.Failure("Role name is already taken.", 400);
61
  }
62
 
63
+ if (dto.MenuCodes != null && dto.MenuCodes.Any())
64
+ {
65
+ var validCodesResult = await ValidatePermissionCodesAsync(dto.MenuCodes);
66
+ if (!validCodesResult.IsSuccess)
67
+ {
68
+ return Result<RoleDto>.Failure(validCodesResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, validCodesResult.StatusCode);
69
+ }
70
+ }
71
+
72
  var role = new TaskTrackingSystem.Database.AppDbContextModels.Role
73
  {
74
  Name = dto.Name,
 
80
  _db.Roles.Add(role);
81
  await _db.SaveChangesAsync();
82
 
83
+ var assignResult = await AssignMenusToRoleAsync(role.Id, new AssignMenusDto { MenuCodes = dto.MenuCodes ?? new List<string>() });
84
+ if (!assignResult.IsSuccess)
85
  {
86
+ return Result<RoleDto>.Failure(assignResult.ErrorMessage ?? ResultMessages.FailedToCreateRole, assignResult.StatusCode);
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
 
89
  var resultDto = new RoleDto
 
113
  return Result.Failure("Role name is already taken by another role.", 400);
114
  }
115
 
116
+ var oldName = role.Name;
117
  role.Name = dto.Name;
118
  role.Description = dto.Description;
119
  role.UpdatedAt = DateTime.UtcNow;
120
  role.UpdatedBy = currentUserId;
121
 
122
+ var relatedRoleMenus = await _db.RoleMenus
123
+ .Where(rm => rm.RoleId == role.Id || rm.RoleCode == oldName)
124
+ .ToListAsync();
125
+
126
+ foreach (var roleMenu in relatedRoleMenus)
127
+ {
128
+ roleMenu.RoleCode = dto.Name;
129
+ }
130
+
131
  _db.Roles.Update(role);
132
  await _db.SaveChangesAsync();
133
  return Result.Success(200);
 
154
  }
155
 
156
  var menuCodes = await _db.RoleMenus
157
+ .Where(rm => rm.RoleId == role.Id && rm.DelFlag == 0)
158
  .Select(rm => rm.MenuCode)
159
  .ToListAsync();
160
 
161
+ if (!menuCodes.Any())
162
+ {
163
+ menuCodes = await _db.RoleMenus
164
+ .Where(rm => rm.RoleCode == role.Name && rm.DelFlag == 0)
165
+ .Select(rm => rm.MenuCode)
166
+ .ToListAsync();
167
+ }
168
+
169
  return Result<List<string>>.Success(menuCodes);
170
  }
171
 
 
182
  return Result.Failure("MenuCodes cannot be null", 400);
183
  }
184
 
185
+ var validCodesResult = await ValidatePermissionCodesAsync(dto.MenuCodes);
186
+ if (!validCodesResult.IsSuccess)
187
+ {
188
+ return Result.Failure(validCodesResult.ErrorMessage ?? ResultMessages.FailedToUpdateRole, validCodesResult.StatusCode);
189
+ }
190
+
191
+ // Remove existing role menus matching either the new RoleId link or the old RoleCode link.
192
  var existingRoleMenus = await _db.RoleMenus
193
+ .Where(rm => rm.RoleId == role.Id || rm.RoleCode == role.Name)
194
  .ToListAsync();
195
 
196
  if (existingRoleMenus.Any())
 
204
  {
205
  _db.RoleMenus.Add(new RoleMenu
206
  {
207
+ RoleMenuId = $"RM_{role.Id}_{i++}_{DateTime.UtcNow.Ticks}",
208
+ RoleId = role.Id,
209
  RoleCode = role.Name,
210
  MenuCode = menuCode,
211
  DelFlag = 0,
 
216
  await _db.SaveChangesAsync();
217
  return Result.Success(200);
218
  }
219
+
220
+ private async Task<Result> ValidatePermissionCodesAsync(IEnumerable<string> menuCodes)
221
+ {
222
+ var validMenuCodes = await _db.MenuAdmins
223
+ .Where(m => m.DelFlag == 0)
224
+ .Select(m => m.MenuCode)
225
+ .ToListAsync();
226
+
227
+ var validActionCodes = await _db.MenuAdminDetails
228
+ .Where(d => d.DelFlag == 0)
229
+ .Select(d => d.MenuDetailCode)
230
+ .ToListAsync();
231
+
232
+ var validCodes = validMenuCodes
233
+ .Concat(validActionCodes)
234
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
235
+
236
+ var invalidCodes = menuCodes
237
+ .Where(code => !string.IsNullOrWhiteSpace(code))
238
+ .Where(code => !validCodes.Contains(code))
239
+ .Distinct(StringComparer.OrdinalIgnoreCase)
240
+ .ToList();
241
+
242
+ if (invalidCodes.Any())
243
+ {
244
+ return Result.Failure(ResultMessages.InvalidPermissionIds(string.Join(", ", invalidCodes)), 400);
245
+ }
246
+
247
+ return Result.Success(200);
248
+ }
249
  }
250
  }
TaskTrackingSystem.WebApi/Features/Task/TaskController.cs CHANGED
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization;
4
  using TaskTrackingSystem.Shared;
5
  using TaskTrackingSystem.Shared.Models.Task;
6
  using TaskTrackingSystem.WebApi.Features.Task;
 
7
 
8
  namespace TaskTrackingSystem.WebApi.Features.Task
9
  {
@@ -22,14 +23,14 @@ namespace TaskTrackingSystem.WebApi.Features.Task
22
  [HttpGet]
23
  public async Task<ActionResult<IEnumerable<TaskDto>>> GetTasks()
24
  {
25
- var tasks = await _taskService.GetAllTasksAsync();
26
  return Ok(tasks);
27
  }
28
 
29
  [HttpGet("{id}")]
30
  public async Task<ActionResult<TaskDto>> GetTask(long id)
31
  {
32
- var task = await _taskService.GetTaskByIdAsync(id);
33
  if (task == null)
34
  {
35
  return NotFound(new { message = $"Task with ID {id} not found." });
@@ -40,7 +41,7 @@ namespace TaskTrackingSystem.WebApi.Features.Task
40
  [HttpPost]
41
  public async Task<ActionResult<Result<TaskDto>>> CreateTask([FromBody] CreateTaskDto createTaskDto)
42
  {
43
- long? currentUserId = null;
44
  var result = await _taskService.CreateTaskAsync(createTaskDto, currentUserId);
45
  return StatusCode(result.StatusCode, result);
46
  }
@@ -48,29 +49,29 @@ namespace TaskTrackingSystem.WebApi.Features.Task
48
  [HttpPut("{id}")]
49
  public async Task<ActionResult<Result>> UpdateTask(long id, [FromBody] UpdateTaskDto updateTaskDto)
50
  {
51
- long? currentUserId = null;
52
- var result = await _taskService.UpdateTaskAsync(id, updateTaskDto, currentUserId);
53
  return StatusCode(result.StatusCode, result);
54
  }
55
 
56
  [HttpDelete("{id}")]
57
  public async Task<ActionResult<Result>> DeleteTask(long id)
58
  {
59
- var result = await _taskService.SoftDeleteTaskAsync(id);
60
  return StatusCode(result.StatusCode, result);
61
  }
62
 
63
  [HttpGet("/api/User/{userId}/tasks")]
64
  public async Task<ActionResult<Result<IEnumerable<TaskDto>>>> GetUserTasks(long userId)
65
  {
66
- var result = await _taskService.GetTasksByUserIdAsync(userId);
67
  return StatusCode(result.StatusCode, result);
68
  }
69
 
70
  [HttpPatch("{id}/status")]
71
  public async Task<IActionResult> UpdateTaskStatus(long id, [FromQuery] long statusId)
72
  {
73
- var result = await _taskService.UpdateTaskStatusAsync(id, statusId);
74
  if (!result.IsSuccess)
75
  {
76
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
 
4
  using TaskTrackingSystem.Shared;
5
  using TaskTrackingSystem.Shared.Models.Task;
6
  using TaskTrackingSystem.WebApi.Features.Task;
7
+ using TaskTrackingSystem.WebApi.Infrastructure;
8
 
9
  namespace TaskTrackingSystem.WebApi.Features.Task
10
  {
 
23
  [HttpGet]
24
  public async Task<ActionResult<IEnumerable<TaskDto>>> GetTasks()
25
  {
26
+ var tasks = await _taskService.GetAllTasksAsync(User.GetRoleName(), User.GetUserId());
27
  return Ok(tasks);
28
  }
29
 
30
  [HttpGet("{id}")]
31
  public async Task<ActionResult<TaskDto>> GetTask(long id)
32
  {
33
+ var task = await _taskService.GetTaskByIdAsync(id, User.GetRoleName(), User.GetUserId());
34
  if (task == null)
35
  {
36
  return NotFound(new { message = $"Task with ID {id} not found." });
 
41
  [HttpPost]
42
  public async Task<ActionResult<Result<TaskDto>>> CreateTask([FromBody] CreateTaskDto createTaskDto)
43
  {
44
+ long? currentUserId = User.GetUserId();
45
  var result = await _taskService.CreateTaskAsync(createTaskDto, currentUserId);
46
  return StatusCode(result.StatusCode, result);
47
  }
 
49
  [HttpPut("{id}")]
50
  public async Task<ActionResult<Result>> UpdateTask(long id, [FromBody] UpdateTaskDto updateTaskDto)
51
  {
52
+ long? currentUserId = User.GetUserId();
53
+ var result = await _taskService.UpdateTaskAsync(id, updateTaskDto, User.GetRoleName(), currentUserId);
54
  return StatusCode(result.StatusCode, result);
55
  }
56
 
57
  [HttpDelete("{id}")]
58
  public async Task<ActionResult<Result>> DeleteTask(long id)
59
  {
60
+ var result = await _taskService.SoftDeleteTaskAsync(id, User.GetRoleName(), User.GetUserId());
61
  return StatusCode(result.StatusCode, result);
62
  }
63
 
64
  [HttpGet("/api/User/{userId}/tasks")]
65
  public async Task<ActionResult<Result<IEnumerable<TaskDto>>>> GetUserTasks(long userId)
66
  {
67
+ var result = await _taskService.GetTasksByUserIdAsync(userId, User.GetRoleName(), User.GetUserId());
68
  return StatusCode(result.StatusCode, result);
69
  }
70
 
71
  [HttpPatch("{id}/status")]
72
  public async Task<IActionResult> UpdateTaskStatus(long id, [FromQuery] long statusId)
73
  {
74
+ var result = await _taskService.UpdateTaskStatusAsync(id, statusId, User.GetRoleName(), User.GetUserId());
75
  if (!result.IsSuccess)
76
  {
77
  return StatusCode(result.StatusCode, new { message = result.ErrorMessage });
TaskTrackingSystem.WebApi/Features/Task/TaskService.cs CHANGED
@@ -20,10 +20,9 @@ namespace TaskTrackingSystem.WebApi.Features.Task
20
  _db = db;
21
  }
22
 
23
- public async Task<IEnumerable<TaskDto>> GetAllTasksAsync()
24
  {
25
- return await _db.Tasks
26
- .Where(t => t.IsDeleted != true)
27
  .OrderBy(t => t.DueDate)
28
  .ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow)
29
  .Select(t => new TaskDto
@@ -47,11 +46,11 @@ namespace TaskTrackingSystem.WebApi.Features.Task
47
  }).ToListAsync();
48
  }
49
 
50
- public async Task<TaskDto?> GetTaskByIdAsync(long id)
51
  {
52
- var task = await _db.Tasks
53
  .Include(t => t.TaskHistories)
54
- .FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
55
 
56
  if (task == null) return null;
57
 
@@ -131,7 +130,7 @@ namespace TaskTrackingSystem.WebApi.Features.Task
131
  return Result<TaskDto>.Success(resultDto, 201);
132
  }
133
 
134
- public async Task<Result> UpdateTaskAsync(long id, UpdateTaskDto dto, long? currentUserId = null)
135
  {
136
  if (string.IsNullOrWhiteSpace(dto.Title))
137
  {
@@ -141,6 +140,11 @@ namespace TaskTrackingSystem.WebApi.Features.Task
141
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
142
  if (task == null) return Result.Failure(ResultMessages.TaskNotFound(id), 404);
143
 
 
 
 
 
 
144
  task.Title = dto.Title;
145
  task.Description = dto.Description;
146
  task.StatusId = dto.StatusId;
@@ -157,19 +161,29 @@ namespace TaskTrackingSystem.WebApi.Features.Task
157
  return Result.Success(200);
158
  }
159
 
160
- public async Task<Result> SoftDeleteTaskAsync(long id)
161
  {
162
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
163
  if (task == null) return Result.Failure(ResultMessages.TaskNotFound(id), 404);
164
 
 
 
 
 
 
165
  task.IsDeleted = true;
166
  _db.Tasks.Update(task);
167
  await _db.SaveChangesAsync();
168
  return Result.Success(200);
169
  }
170
 
171
- public async Task<Result<IEnumerable<TaskDto>>> GetTasksByUserIdAsync(long userId)
172
  {
 
 
 
 
 
173
  var userExists = await _db.Users.AnyAsync(u => u.Id == userId && !u.IsDeleted);
174
  if (!userExists)
175
  {
@@ -202,7 +216,7 @@ namespace TaskTrackingSystem.WebApi.Features.Task
202
  return Result<IEnumerable<TaskDto>>.Success(tasks);
203
  }
204
 
205
- public async Task<Result> UpdateTaskStatusAsync(long id, long statusId, long? currentUserId = null)
206
  {
207
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
208
  if (task == null)
@@ -210,6 +224,11 @@ namespace TaskTrackingSystem.WebApi.Features.Task
210
  return Result.Failure(ResultMessages.TaskNotFound(id), 404);
211
  }
212
 
 
 
 
 
 
213
  task.StatusId = statusId;
214
  task.UpdatedAt = DateTime.UtcNow;
215
  task.UpdatedBy = currentUserId;
@@ -219,5 +238,35 @@ namespace TaskTrackingSystem.WebApi.Features.Task
219
 
220
  return Result.Success(200);
221
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  }
223
  }
 
20
  _db = db;
21
  }
22
 
23
+ public async Task<IEnumerable<TaskDto>> GetAllTasksAsync(string roleName, long currentUserId)
24
  {
25
+ return await BuildAccessibleTaskQuery(roleName, currentUserId)
 
26
  .OrderBy(t => t.DueDate)
27
  .ThenByDescending(t => t.CreatedAt ?? DateTime.UtcNow)
28
  .Select(t => new TaskDto
 
46
  }).ToListAsync();
47
  }
48
 
49
+ public async Task<TaskDto?> GetTaskByIdAsync(long id, string roleName, long currentUserId)
50
  {
51
+ var task = await BuildAccessibleTaskQuery(roleName, currentUserId)
52
  .Include(t => t.TaskHistories)
53
+ .FirstOrDefaultAsync(t => t.Id == id);
54
 
55
  if (task == null) return null;
56
 
 
130
  return Result<TaskDto>.Success(resultDto, 201);
131
  }
132
 
133
+ public async Task<Result> UpdateTaskAsync(long id, UpdateTaskDto dto, string roleName, long? currentUserId = null)
134
  {
135
  if (string.IsNullOrWhiteSpace(dto.Title))
136
  {
 
140
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
141
  if (task == null) return Result.Failure(ResultMessages.TaskNotFound(id), 404);
142
 
143
+ if (!IsElevated(roleName) && currentUserId.HasValue && !await CanEditTaskAsync(task, currentUserId.Value))
144
+ {
145
+ return Result.Failure("You do not have access to update this task.", 403);
146
+ }
147
+
148
  task.Title = dto.Title;
149
  task.Description = dto.Description;
150
  task.StatusId = dto.StatusId;
 
161
  return Result.Success(200);
162
  }
163
 
164
+ public async Task<Result> SoftDeleteTaskAsync(long id, string roleName, long currentUserId)
165
  {
166
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
167
  if (task == null) return Result.Failure(ResultMessages.TaskNotFound(id), 404);
168
 
169
+ if (!IsElevated(roleName) && task.CreatedBy != currentUserId && task.AssignedTo != currentUserId)
170
+ {
171
+ return Result.Failure("You do not have access to delete this task.", 403);
172
+ }
173
+
174
  task.IsDeleted = true;
175
  _db.Tasks.Update(task);
176
  await _db.SaveChangesAsync();
177
  return Result.Success(200);
178
  }
179
 
180
+ public async Task<Result<IEnumerable<TaskDto>>> GetTasksByUserIdAsync(long userId, string roleName, long currentUserId)
181
  {
182
+ if (!IsElevated(roleName) && userId != currentUserId)
183
+ {
184
+ return Result<IEnumerable<TaskDto>>.Failure("You do not have access to view these tasks.", 403);
185
+ }
186
+
187
  var userExists = await _db.Users.AnyAsync(u => u.Id == userId && !u.IsDeleted);
188
  if (!userExists)
189
  {
 
216
  return Result<IEnumerable<TaskDto>>.Success(tasks);
217
  }
218
 
219
+ public async Task<Result> UpdateTaskStatusAsync(long id, long statusId, string roleName, long currentUserId)
220
  {
221
  var task = await _db.Tasks.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true);
222
  if (task == null)
 
224
  return Result.Failure(ResultMessages.TaskNotFound(id), 404);
225
  }
226
 
227
+ if (!IsElevated(roleName) && task.AssignedTo != currentUserId && task.CreatedBy != currentUserId)
228
+ {
229
+ return Result.Failure("You do not have access to update this task.", 403);
230
+ }
231
+
232
  task.StatusId = statusId;
233
  task.UpdatedAt = DateTime.UtcNow;
234
  task.UpdatedBy = currentUserId;
 
238
 
239
  return Result.Success(200);
240
  }
241
+
242
+ private IQueryable<TaskTrackingSystem.Database.AppDbContextModels.Task> BuildAccessibleTaskQuery(string roleName, long currentUserId)
243
+ {
244
+ var query = _db.Tasks.Where(t => t.IsDeleted != true);
245
+
246
+ if (IsElevated(roleName))
247
+ {
248
+ return query;
249
+ }
250
+
251
+ return query.Where(t =>
252
+ t.AssignedTo == currentUserId ||
253
+ t.CreatedBy == currentUserId);
254
+ }
255
+
256
+ private async Task<bool> CanEditTaskAsync(TaskTrackingSystem.Database.AppDbContextModels.Task task, long currentUserId)
257
+ {
258
+ if (task.CreatedBy == currentUserId || task.AssignedTo == currentUserId)
259
+ {
260
+ return true;
261
+ }
262
+
263
+ return await _db.ProjectMembers.AnyAsync(pm => pm.ProjectId == task.ProjectId && pm.UserId == currentUserId);
264
+ }
265
+
266
+ private static bool IsElevated(string roleName)
267
+ {
268
+ return roleName.Equals("Admin", StringComparison.OrdinalIgnoreCase) ||
269
+ roleName.Equals("Manager", StringComparison.OrdinalIgnoreCase);
270
+ }
271
  }
272
  }
TaskTrackingSystem.WebApi/Features/User/UserService.cs CHANGED
@@ -1,4 +1,5 @@
1
  using Microsoft.EntityFrameworkCore;
 
2
  using System;
3
  using System.Collections.Generic;
4
  using System.Linq;
@@ -13,10 +14,12 @@ namespace TaskTrackingSystem.WebApi.Features.User
13
  public class UserService
14
  {
15
  private readonly AppDbContext _db;
 
16
 
17
- public UserService(AppDbContext db)
18
  {
19
  _db = db;
 
20
  }
21
 
22
  public async Task<IEnumerable<UserDto>> GetAllUsersAsync()
@@ -33,7 +36,7 @@ namespace TaskTrackingSystem.WebApi.Features.User
33
  Phone = u.Phone,
34
  RoleId = u.RoleId,
35
  IsActive = u.IsActive,
36
- CreatedAt = (DateTime)u.CreatedAt
37
  }).ToListAsync();
38
  }
39
 
@@ -54,7 +57,7 @@ namespace TaskTrackingSystem.WebApi.Features.User
54
  Phone = user.Phone,
55
  RoleId = user.RoleId,
56
  IsActive = user.IsActive,
57
- CreatedAt = (DateTime)user.CreatedAt
58
  };
59
  }
60
 
@@ -77,15 +80,13 @@ namespace TaskTrackingSystem.WebApi.Features.User
77
  return Result<UserDto>.Failure("Email is already registered.", 400);
78
  }
79
 
80
- string fakePasswordHash = "HASHED_" + dto.Password;
81
-
82
  var user = new TaskTrackingSystem.Database.AppDbContextModels.User
83
  {
84
  Username = dto.Username,
85
  FirstName = dto.FirstName,
86
  LastName = dto.LastName,
87
  Email = dto.Email,
88
- PasswordHash = fakePasswordHash,
89
  Phone = dto.Phone,
90
  RoleId = dto.RoleId,
91
  IsActive = true,
@@ -93,6 +94,8 @@ namespace TaskTrackingSystem.WebApi.Features.User
93
  CreatedBy = currentUserId
94
  };
95
 
 
 
96
 
97
  _db.Users.Add(user);
98
  await _db.SaveChangesAsync();
@@ -159,4 +162,4 @@ namespace TaskTrackingSystem.WebApi.Features.User
159
  return Result.Success(200);
160
  }
161
  }
162
- }
 
1
  using Microsoft.EntityFrameworkCore;
2
+ using Microsoft.AspNetCore.Identity;
3
  using System;
4
  using System.Collections.Generic;
5
  using System.Linq;
 
14
  public class UserService
15
  {
16
  private readonly AppDbContext _db;
17
+ private readonly IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User> _passwordHasher;
18
 
19
+ public UserService(AppDbContext db, IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User> passwordHasher)
20
  {
21
  _db = db;
22
+ _passwordHasher = passwordHasher;
23
  }
24
 
25
  public async Task<IEnumerable<UserDto>> GetAllUsersAsync()
 
36
  Phone = u.Phone,
37
  RoleId = u.RoleId,
38
  IsActive = u.IsActive,
39
+ CreatedAt = u.CreatedAt ?? DateTime.UtcNow
40
  }).ToListAsync();
41
  }
42
 
 
57
  Phone = user.Phone,
58
  RoleId = user.RoleId,
59
  IsActive = user.IsActive,
60
+ CreatedAt = user.CreatedAt ?? DateTime.UtcNow
61
  };
62
  }
63
 
 
80
  return Result<UserDto>.Failure("Email is already registered.", 400);
81
  }
82
 
 
 
83
  var user = new TaskTrackingSystem.Database.AppDbContextModels.User
84
  {
85
  Username = dto.Username,
86
  FirstName = dto.FirstName,
87
  LastName = dto.LastName,
88
  Email = dto.Email,
89
+ PasswordHash = string.Empty,
90
  Phone = dto.Phone,
91
  RoleId = dto.RoleId,
92
  IsActive = true,
 
94
  CreatedBy = currentUserId
95
  };
96
 
97
+ user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
98
+
99
 
100
  _db.Users.Add(user);
101
  await _db.SaveChangesAsync();
 
162
  return Result.Success(200);
163
  }
164
  }
165
+ }
TaskTrackingSystem.WebApi/Infrastructure/ClaimsPrincipalExtensions.cs ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ using System.Security.Claims;
2
+
3
+ namespace TaskTrackingSystem.WebApi.Infrastructure;
4
+
5
+ public static class ClaimsPrincipalExtensions
6
+ {
7
+ public static long GetUserId(this ClaimsPrincipal user)
8
+ {
9
+ var value = user.FindFirstValue(ClaimTypes.NameIdentifier);
10
+ return long.TryParse(value, out var id) ? id : 0;
11
+ }
12
+
13
+ public static long GetRoleId(this ClaimsPrincipal user)
14
+ {
15
+ var value = user.FindFirstValue("role_id");
16
+ return long.TryParse(value, out var id) ? id : 0;
17
+ }
18
+
19
+ public static string GetRoleName(this ClaimsPrincipal user)
20
+ {
21
+ return user.FindFirstValue(ClaimTypes.Role) ?? string.Empty;
22
+ }
23
+
24
+ public static bool IsAdmin(this ClaimsPrincipal user)
25
+ {
26
+ return user.IsInRole("Admin");
27
+ }
28
+
29
+ public static bool IsManager(this ClaimsPrincipal user)
30
+ {
31
+ return user.IsInRole("Manager");
32
+ }
33
+ }
TaskTrackingSystem.WebApi/Program.cs CHANGED
@@ -1,5 +1,6 @@
1
  using Microsoft.EntityFrameworkCore;
2
  using Microsoft.AspNetCore.Authorization;
 
3
  using TaskTrackingSystem.Database.AppDbContextModels;
4
  using Microsoft.AspNetCore.Authentication.JwtBearer;
5
  using System.Text;
@@ -29,6 +30,7 @@ builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Task.TaskService>(
29
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Dashboard.DashboardService>();
30
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Report.ReportService>();
31
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Menu.MenuService>();
 
32
  // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
33
  builder.Services.AddAuthorization();
34
  builder.Services.AddEndpointsApiExplorer();
@@ -40,7 +42,7 @@ builder.Services.AddAuthentication(options =>
40
  }).AddJwtBearer(options =>
41
  {
42
  var jwtSettings = builder.Configuration.GetSection("JwtSettings");
43
- var key = jwtSettings["Key"];
44
  options.TokenValidationParameters = new TokenValidationParameters
45
  {
46
  ValidateIssuer = true,
 
1
  using Microsoft.EntityFrameworkCore;
2
  using Microsoft.AspNetCore.Authorization;
3
+ using Microsoft.AspNetCore.Identity;
4
  using TaskTrackingSystem.Database.AppDbContextModels;
5
  using Microsoft.AspNetCore.Authentication.JwtBearer;
6
  using System.Text;
 
30
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Dashboard.DashboardService>();
31
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Report.ReportService>();
32
  builder.Services.AddScoped<TaskTrackingSystem.WebApi.Features.Menu.MenuService>();
33
+ builder.Services.AddScoped<IPasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>, PasswordHasher<TaskTrackingSystem.Database.AppDbContextModels.User>>();
34
  // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
35
  builder.Services.AddAuthorization();
36
  builder.Services.AddEndpointsApiExplorer();
 
42
  }).AddJwtBearer(options =>
43
  {
44
  var jwtSettings = builder.Configuration.GetSection("JwtSettings");
45
+ var key = jwtSettings["Key"] ?? "SUPER_SECRET_KEY_THAT_IS_LONG_ENOUGH_12345";
46
  options.TokenValidationParameters = new TokenValidationParameters
47
  {
48
  ValidateIssuer = true,
TaskTrackingSystem.WebApi/appsettings.json CHANGED
@@ -11,7 +11,10 @@
11
  "Issuer": "TaskTrackingSystem",
12
  "Audience": "Users"
13
  },
 
 
 
14
  "ConnectionStrings": {
15
  "DefaultConnection": "Server=.;Database=TTS;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=5;"
16
  }
17
- }
 
11
  "Issuer": "TaskTrackingSystem",
12
  "Audience": "Users"
13
  },
14
+ "PasswordReset": {
15
+ "RecoveryCode": "TASKIFY-RESET-2026"
16
+ },
17
  "ConnectionStrings": {
18
  "DefaultConnection": "Server=.;Database=TTS;Trusted_Connection=True;TrustServerCertificate=True;Connect Timeout=5;"
19
  }
20
+ }
TaskTrackingSystem.WebApp/AccountEndpoints.cs CHANGED
@@ -15,8 +15,9 @@ public static class AccountEndpoints
15
  {
16
  app.MapPost("/account/login", LoginAsync);
17
  app.MapPost("/account/register", RegisterAsync);
18
- app.MapGet("/account/logout", (Delegate)LogoutAsync);
19
- app.MapPost("/account/logout", (Delegate)LogoutAsync);
 
20
  }
21
 
22
  private static async Task<IResult> LoginAsync(
@@ -155,10 +156,57 @@ public static class AccountEndpoints
155
  return Results.Redirect($"/register?error={Uri.EscapeDataString(errorMessage)}");
156
  }
157
 
158
- private static async Task<IResult> LogoutAsync(HttpContext context)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  {
160
  await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
161
  context.Response.Cookies.Delete(CookieAuthenticationDefaults.AuthenticationScheme);
 
162
  return Results.Redirect("/login?loggedOut=true");
163
  }
164
 
@@ -175,6 +223,11 @@ public static class AccountEndpoints
175
  claims.Add(new Claim(ClaimTypes.Role, authResult.RoleName));
176
  }
177
 
 
 
 
 
 
178
  var userId = TryGetUserIdFromJwt(authResult.Token);
179
  if (!string.IsNullOrWhiteSpace(userId))
180
  {
 
15
  {
16
  app.MapPost("/account/login", LoginAsync);
17
  app.MapPost("/account/register", RegisterAsync);
18
+ app.MapPost("/account/reset-password", ResetPasswordAsync);
19
+ app.MapGet("/account/logout", LogoutAsync);
20
+ app.MapPost("/account/logout", LogoutAsync);
21
  }
22
 
23
  private static async Task<IResult> LoginAsync(
 
156
  return Results.Redirect($"/register?error={Uri.EscapeDataString(errorMessage)}");
157
  }
158
 
159
+ private static async Task<IResult> ResetPasswordAsync(
160
+ HttpContext context,
161
+ IHttpClientFactory httpClientFactory,
162
+ [FromForm] ResetPasswordDto resetPasswordDto)
163
+ {
164
+ HttpResponseMessage response;
165
+ try
166
+ {
167
+ var client = httpClientFactory.CreateClient("WebApi");
168
+ response = await client.PostAsJsonAsync("Auth/reset-password", resetPasswordDto);
169
+ }
170
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException)
171
+ {
172
+ return Results.Redirect($"/reset-password?error={Uri.EscapeDataString("Unable to reach the API server. Please ensure TaskTrackingSystem.WebApi is running.")}");
173
+ }
174
+
175
+ if (response.IsSuccessStatusCode)
176
+ {
177
+ var result = await response.Content.ReadFromJsonAsync<Result>(Serialization.CaseInsensitive);
178
+ if (result?.IsSuccess == true)
179
+ {
180
+ return Results.Redirect("/login?reset=true");
181
+ }
182
+ }
183
+
184
+ var errorMessage = "Failed to reset password.";
185
+ if (response.Content != null)
186
+ {
187
+ try
188
+ {
189
+ var contentString = await response.Content.ReadAsStringAsync();
190
+ var result = JsonSerializer.Deserialize<Result>(contentString, Serialization.CaseInsensitive);
191
+ if (result != null && !string.IsNullOrWhiteSpace(result.ErrorMessage))
192
+ {
193
+ errorMessage = result.ErrorMessage;
194
+ }
195
+ }
196
+ catch
197
+ {
198
+ // Keep default error message.
199
+ }
200
+ }
201
+
202
+ return Results.Redirect($"/reset-password?error={Uri.EscapeDataString(errorMessage)}");
203
+ }
204
+
205
+ private static async Task<IResult> LogoutAsync(HttpContext context, UserSessionState sessionState)
206
  {
207
  await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
208
  context.Response.Cookies.Delete(CookieAuthenticationDefaults.AuthenticationScheme);
209
+ sessionState.ClearSession();
210
  return Results.Redirect("/login?loggedOut=true");
211
  }
212
 
 
223
  claims.Add(new Claim(ClaimTypes.Role, authResult.RoleName));
224
  }
225
 
226
+ if (authResult.RoleId > 0)
227
+ {
228
+ claims.Add(new Claim("role_id", authResult.RoleId.ToString()));
229
+ }
230
+
231
  var userId = TryGetUserIdFromJwt(authResult.Token);
232
  if (!string.IsNullOrWhiteSpace(userId))
233
  {
TaskTrackingSystem.WebApp/Components/CustomAuthenticationStateProvider.cs CHANGED
@@ -48,5 +48,18 @@ public class CustomAuthenticationStateProvider : AuthenticationStateProvider
48
  {
49
  _sessionState.Token = token;
50
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
  }
 
48
  {
49
  _sessionState.Token = token;
50
  }
51
+
52
+ var roleId = user.FindFirst("role_id")?.Value;
53
+ if (!string.IsNullOrWhiteSpace(roleId))
54
+ {
55
+ _sessionState.CachedMenuRoleId = $"id:{roleId}";
56
+ return;
57
+ }
58
+
59
+ var roleName = user.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
60
+ if (!string.IsNullOrWhiteSpace(roleName))
61
+ {
62
+ _sessionState.CachedMenuRoleId = $"name:{roleName}";
63
+ }
64
  }
65
  }
TaskTrackingSystem.WebApp/Components/Layout/MainLayout.razor CHANGED
@@ -135,8 +135,8 @@
135
  return;
136
  }
137
 
138
- await MenuAuthorization.GetUserMenusAsync(user);
139
- isAuthorizedPage = MenuAuthorization.IsRouteAllowed(user, relativePath);
140
  await InvokeAsync(StateHasChanged);
141
  }
142
 
 
135
  return;
136
  }
137
 
138
+ var menus = await MenuAuthorization.GetUserMenusAsync(user);
139
+ isAuthorizedPage = MenuAuthorization.IsRouteAllowed(menus, relativePath);
140
  await InvokeAsync(StateHasChanged);
141
  }
142
 
TaskTrackingSystem.WebApp/Components/Layout/NavMenu.razor CHANGED
@@ -1,4 +1,6 @@
 
1
  @using Microsoft.AspNetCore.Components.Authorization
 
2
  @using TaskTrackingSystem.Shared.Models.Menu
3
  @using Microsoft.JSInterop
4
  @implements IDisposable
@@ -7,25 +9,16 @@
7
  @inject NavigationManager Navigation
8
  @inject IJSRuntime JS
9
 
10
-
11
  <nav class="flex-1 space-y-1 px-4 py-6 overflow-y-auto">
12
  @if (menus.Any())
13
  {
14
  @foreach (var menu in menus)
15
  {
16
- @if (menu.SubMenus != null && menu.SubMenus.Count > 0)
17
  {
18
- <!-- Parent with sub-menus: collapsible dropdown -->
19
  <button class="nav-dropdown-toggle @(IsMenuGroupExpanded(menu.MenuCode) ? "expanded" : "")"
20
  @onclick="() => ToggleMenuGroup(menu.MenuCode)">
21
- @if (!string.IsNullOrEmpty(menu.Icon))
22
- {
23
- <i data-lucide="@menu.Icon" class="h-4 w-4 mr-3 text-slate-400 shrink-0"></i>
24
- }
25
- else
26
- {
27
- <i data-lucide="circle" class="h-4 w-4 mr-3 text-slate-400 shrink-0"></i>
28
- }
29
  <span class="flex-1 text-left">@menu.MenuName</span>
30
  <i data-lucide="chevron-down" class="nav-chevron h-4 w-4 text-slate-400 shrink-0"></i>
31
  </button>
@@ -37,14 +30,7 @@
37
  <NavLink class="nav-link-item nav-child-item"
38
  href="@MenuAuthorizationService.NormalizeMenuHref(sub.MenuUrl)"
39
  Match="@GetNavLinkMatch(sub.MenuUrl)">
40
- @if (!string.IsNullOrEmpty(sub.Icon))
41
- {
42
- <i data-lucide="@sub.Icon" class="h-3.5 w-3.5 mr-3 text-slate-400 shrink-0"></i>
43
- }
44
- else
45
- {
46
- <i data-lucide="circle" class="h-3.5 w-3.5 mr-3 text-slate-400 shrink-0"></i>
47
- }
48
  <span>@sub.MenuName</span>
49
  </NavLink>
50
  }
@@ -53,70 +39,27 @@
53
  }
54
  else
55
  {
56
- <!-- Simple nav link -->
57
  <NavLink class="nav-link-item"
58
  href="@MenuAuthorizationService.NormalizeMenuHref(menu.MenuUrl)"
59
  Match="@(string.IsNullOrWhiteSpace(menu.MenuUrl) || menu.MenuUrl.Trim('/') is "dashboard" or "" ? NavLinkMatch.All : NavLinkMatch.Prefix)">
60
- @if (!string.IsNullOrEmpty(menu.Icon))
61
- {
62
- <i data-lucide="@menu.Icon" class="h-4 w-4 mr-3 text-slate-400 shrink-0"></i>
63
- }
64
- else
65
- {
66
- <i data-lucide="circle" class="h-4 w-4 mr-3 text-slate-400 shrink-0"></i>
67
- }
68
  <span>@menu.MenuName</span>
69
  </NavLink>
70
  }
71
  }
72
  }
73
- else if (showRoleFallback)
74
  {
75
- @foreach (var link in fallbackLinks)
76
- {
77
- @if (link.Children != null && link.Children.Count > 0)
78
- {
79
- <!-- Fallback parent with children: collapsible dropdown -->
80
- <button class="nav-dropdown-toggle @(IsFallbackGroupExpanded(link.Label) ? "expanded" : "")"
81
- @onclick="() => ToggleFallbackGroup(link.Label)">
82
- <i data-lucide="@link.Icon" class="h-4 w-4 mr-3 text-slate-400 shrink-0"></i>
83
- <span class="flex-1 text-left">@link.Label</span>
84
- <i data-lucide="chevron-down" class="nav-chevron h-4 w-4 text-slate-400 shrink-0"></i>
85
- </button>
86
- @if (IsFallbackGroupExpanded(link.Label))
87
- {
88
- <div class="nav-dropdown-children">
89
- @foreach (var child in link.Children)
90
- {
91
- <NavLink class="nav-link-item nav-child-item"
92
- href="@child.Href" Match="@child.Match">
93
- <i data-lucide="@child.Icon" class="h-3.5 w-3.5 mr-3 text-slate-400 shrink-0"></i>
94
- <span>@child.Label</span>
95
- </NavLink>
96
- }
97
- </div>
98
- }
99
- }
100
- else
101
- {
102
- <!-- Simple fallback link -->
103
- <NavLink class="nav-link-item"
104
- href="@link.Href" Match="@link.Match">
105
- <i data-lucide="@link.Icon" class="h-4 w-4 mr-3 text-slate-400 shrink-0"></i>
106
- <span>@link.Label</span>
107
- </NavLink>
108
- }
109
- }
110
  }
111
  </nav>
112
 
113
  @code {
114
  private List<MenuDto> menus = new();
115
- private bool showRoleFallback;
116
- private List<FallbackLink> fallbackLinks = new();
117
  private string currentPath = string.Empty;
118
  private HashSet<string> expandedMenuGroups = new();
119
- private HashSet<string> expandedFallbackGroups = new();
120
 
121
  protected override async Task OnInitializedAsync()
122
  {
@@ -130,33 +73,14 @@
130
 
131
  if (user.Identity?.IsAuthenticated == true)
132
  {
133
- fallbackLinks = BuildFallbackLinks(user);
134
- showRoleFallback = fallbackLinks.Count > 0;
135
-
136
- // Auto-expand fallback groups whose children match the current route
137
- foreach (var link in fallbackLinks)
138
- {
139
- if (link.Children != null && IsFallbackExpanded(link.Children))
140
- {
141
- expandedFallbackGroups.Add(link.Label);
142
- }
143
- }
144
-
145
  menus = await MenuAuthorization.GetUserMenusAsync(user);
146
- if (menus.Count > 0)
147
- {
148
- showRoleFallback = false;
149
-
150
- // Build URL-based hierarchy for menus that have no SubMenus from the API
151
- BuildUrlHierarchy(menus);
152
 
153
- // Auto-expand dynamic menu groups whose sub-menus match the current route
154
- foreach (var menu in menus)
 
155
  {
156
- if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus))
157
- {
158
- expandedMenuGroups.Add(menu.MenuCode);
159
- }
160
  }
161
  }
162
  }
@@ -171,14 +95,6 @@
171
  {
172
  currentPath = Navigation.ToBaseRelativePath(e.Location).Trim('/');
173
 
174
- // Auto-expand groups on navigation
175
- foreach (var link in fallbackLinks)
176
- {
177
- if (link.Children != null && IsFallbackExpanded(link.Children))
178
- {
179
- expandedFallbackGroups.Add(link.Label);
180
- }
181
- }
182
  foreach (var menu in menus)
183
  {
184
  if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus))
@@ -190,21 +106,15 @@
190
  InvokeAsync(StateHasChanged);
191
  }
192
 
193
- // Toggle helpers
194
  private void ToggleMenuGroup(string menuCode)
195
  {
196
  if (!expandedMenuGroups.Remove(menuCode))
 
197
  expandedMenuGroups.Add(menuCode);
198
- }
199
-
200
- private void ToggleFallbackGroup(string label)
201
- {
202
- if (!expandedFallbackGroups.Remove(label))
203
- expandedFallbackGroups.Add(label);
204
  }
205
 
206
  private bool IsMenuGroupExpanded(string menuCode) => expandedMenuGroups.Contains(menuCode);
207
- private bool IsFallbackGroupExpanded(string label) => expandedFallbackGroups.Contains(label);
208
 
209
  private bool IsMenuExpanded(string menuCode, IEnumerable<MenuDto> subMenus)
210
  {
@@ -213,12 +123,6 @@
213
  currentPath.StartsWith(s.MenuUrl.Trim('/'), StringComparison.OrdinalIgnoreCase));
214
  }
215
 
216
- private bool IsFallbackExpanded(IEnumerable<FallbackLink> children)
217
- {
218
- return children.Any(c =>
219
- currentPath.StartsWith(c.Href.TrimStart('/'), StringComparison.OrdinalIgnoreCase));
220
- }
221
-
222
  private static NavLinkMatch GetNavLinkMatch(string? menuUrl)
223
  {
224
  return string.IsNullOrWhiteSpace(menuUrl) || menuUrl.Trim('/') is "dashboard" or ""
@@ -226,12 +130,6 @@
226
  : NavLinkMatch.Prefix;
227
  }
228
 
229
- /// <summary>
230
- /// Post-processes a flat menu list: if a parent menu (e.g. /reports) has no SubMenus
231
- /// but other top-level items share that URL prefix (e.g. /reports/employees),
232
- /// nest them as SubMenus and remove from the top-level list.
233
- /// Also injects known report sub-pages if the Reports menu has no children.
234
- /// </summary>
235
  private static void BuildUrlHierarchy(List<MenuDto> menuList)
236
  {
237
  var toRemove = new List<MenuDto>();
@@ -239,17 +137,21 @@
239
  foreach (var parent in menuList)
240
  {
241
  if (string.IsNullOrWhiteSpace(parent.MenuUrl))
 
242
  continue;
 
243
 
244
- // Only process parents that don't already have SubMenus from the API
245
  if (parent.SubMenus != null && parent.SubMenus.Count > 0)
 
246
  continue;
 
247
 
248
  var parentPath = parent.MenuUrl.Trim('/');
249
  if (string.IsNullOrEmpty(parentPath))
 
250
  continue;
 
251
 
252
- // Find other top-level items whose URL starts with this parent's URL + "/"
253
  var children = menuList
254
  .Where(m => m != parent
255
  && !string.IsNullOrWhiteSpace(m.MenuUrl)
@@ -268,99 +170,23 @@
268
  {
269
  menuList.Remove(item);
270
  }
271
-
272
- // Inject known report sub-pages if the Reports menu exists but still has no children
273
- var reportsMenu = menuList.FirstOrDefault(m =>
274
- !string.IsNullOrWhiteSpace(m.MenuUrl) &&
275
- m.MenuUrl.Trim('/').Equals("reports", StringComparison.OrdinalIgnoreCase));
276
-
277
- if (reportsMenu != null && (reportsMenu.SubMenus == null || reportsMenu.SubMenus.Count == 0))
278
- {
279
- reportsMenu.SubMenus = new List<MenuDto>
280
- {
281
- new() { MenuCode = "RPT_EMP", MenuName = "Employee Report", MenuUrl = "/reports/employees", Icon = "users", OrderNo = 1 },
282
- new() { MenuCode = "RPT_PRJ", MenuName = "Project Progress", MenuUrl = "/reports/projects", Icon = "folder-kanban", OrderNo = 2 },
283
- new() { MenuCode = "RPT_TSK", MenuName = "Task Report", MenuUrl = "/reports/tasks", Icon = "check-square", OrderNo = 3 },
284
- new() { MenuCode = "RPT_OVD", MenuName = "Overdue Tasks", MenuUrl = "/reports/overdue", Icon = "alert-circle", OrderNo = 4 },
285
- new() { MenuCode = "RPT_TMS", MenuName = "Time Tracking", MenuUrl = "/reports/timesheet", Icon = "clock", OrderNo = 5 }
286
- };
287
- }
288
-
289
- // Inject known project sub-pages if the Projects menu exists but still has no children
290
- var projectsMenu = menuList.FirstOrDefault(m =>
291
- !string.IsNullOrWhiteSpace(m.MenuUrl) &&
292
- m.MenuUrl.Trim('/').Equals("projects", StringComparison.OrdinalIgnoreCase));
293
-
294
- if (projectsMenu != null && (projectsMenu.SubMenus == null || projectsMenu.SubMenus.Count == 0))
295
- {
296
- projectsMenu.SubMenus = new List<MenuDto>
297
- {
298
- new() { MenuCode = "PRJ_ALL", MenuName = "All Projects", MenuUrl = "/projects/all", Icon = "folder-kanban", OrderNo = 1 },
299
- new() { MenuCode = "PRJ_ASN", MenuName = "Project Assign", MenuUrl = "/projects/assign", Icon = "user-plus", OrderNo = 2 }
300
- };
301
- }
302
-
303
- // Inject known task sub-pages if the Tasks menu exists but still has no children
304
- var tasksMenu = menuList.FirstOrDefault(m =>
305
- !string.IsNullOrWhiteSpace(m.MenuUrl) &&
306
- m.MenuUrl.Trim('/').Equals("tasks", StringComparison.OrdinalIgnoreCase));
307
-
308
- if (tasksMenu != null && (tasksMenu.SubMenus == null || tasksMenu.SubMenus.Count == 0))
309
- {
310
- tasksMenu.SubMenus = new List<MenuDto>
311
- {
312
- new() { MenuCode = "TSK_ALL", MenuName = "All Tasks", MenuUrl = "/tasks/all", Icon = "check-square", OrderNo = 1 },
313
- new() { MenuCode = "TSK_ASN", MenuName = "Task Assign", MenuUrl = "/tasks/assign", Icon = "user-check", OrderNo = 2 }
314
- };
315
- }
316
  }
317
 
318
- private static List<FallbackLink> BuildFallbackLinks(System.Security.Claims.ClaimsPrincipal user)
319
  {
320
- var role = user.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "Member";
321
- var projectChildren = new List<FallbackLink>
322
- {
323
- new("/projects/all", "All Projects", "folder-kanban", NavLinkMatch.Prefix)
324
- };
325
- var taskChildren = new List<FallbackLink>
326
- {
327
- new("/tasks/all", "All Tasks", "check-square", NavLinkMatch.Prefix)
328
- };
329
 
330
- if (role.Equals("Admin", StringComparison.OrdinalIgnoreCase))
331
- {
332
- projectChildren.Add(new("/projects/assign", "Project Assign", "user-plus", NavLinkMatch.Prefix));
333
- taskChildren.Add(new("/tasks/assign", "Task Assign", "user-check", NavLinkMatch.Prefix));
334
- }
335
-
336
- var links = new List<FallbackLink>
337
- {
338
- new("/dashboard", "Dashboard", "layout-dashboard", NavLinkMatch.All),
339
- new FallbackLink("/projects", "Projects", "folder-kanban", NavLinkMatch.Prefix, projectChildren),
340
- new FallbackLink("/tasks", "Tasks", "check-square", NavLinkMatch.Prefix, taskChildren)
341
- };
342
-
343
- if (role.Equals("Admin", StringComparison.OrdinalIgnoreCase))
344
- {
345
- links.Add(new FallbackLink(
346
- "/reports",
347
- "Reports",
348
- "bar-chart-3",
349
- NavLinkMatch.Prefix,
350
- new List<FallbackLink>
351
- {
352
- new("/reports/employees", "Employee Report", "users", NavLinkMatch.Prefix),
353
- new("/reports/projects", "Project Progress", "folder-kanban", NavLinkMatch.Prefix),
354
- new("/reports/tasks", "Task Report", "check-square", NavLinkMatch.Prefix),
355
- new("/reports/overdue", "Overdue Tasks", "alert-circle", NavLinkMatch.Prefix),
356
- new("/reports/timesheet", "Time Tracking", "clock", NavLinkMatch.Prefix)
357
- }));
358
- links.Add(new("/roles", "Roles", "shield-check", NavLinkMatch.Prefix));
359
- links.Add(new("/users", "Users", "users", NavLinkMatch.Prefix));
360
- }
361
-
362
- return links;
363
- }
364
 
365
  protected override async Task OnAfterRenderAsync(bool firstRender)
366
  {
@@ -370,7 +196,6 @@
370
  }
371
  catch
372
  {
373
- // Ignore JS errors during prerendering or if JS is not available yet
374
  }
375
  }
376
 
@@ -378,11 +203,4 @@
378
  {
379
  Navigation.LocationChanged -= OnLocationChanged;
380
  }
381
-
382
- private sealed record FallbackLink(
383
- string Href,
384
- string Label,
385
- string Icon,
386
- NavLinkMatch Match,
387
- List<FallbackLink>? Children = null);
388
  }
 
1
+ @using Microsoft.AspNetCore.Components
2
  @using Microsoft.AspNetCore.Components.Authorization
3
+ @using Microsoft.AspNetCore.Components.Routing
4
  @using TaskTrackingSystem.Shared.Models.Menu
5
  @using Microsoft.JSInterop
6
  @implements IDisposable
 
9
  @inject NavigationManager Navigation
10
  @inject IJSRuntime JS
11
 
 
12
  <nav class="flex-1 space-y-1 px-4 py-6 overflow-y-auto">
13
  @if (menus.Any())
14
  {
15
  @foreach (var menu in menus)
16
  {
17
+ if (menu.SubMenus != null && menu.SubMenus.Count > 0)
18
  {
 
19
  <button class="nav-dropdown-toggle @(IsMenuGroupExpanded(menu.MenuCode) ? "expanded" : "")"
20
  @onclick="() => ToggleMenuGroup(menu.MenuCode)">
21
+ @RenderIcon(menu.Icon)
 
 
 
 
 
 
 
22
  <span class="flex-1 text-left">@menu.MenuName</span>
23
  <i data-lucide="chevron-down" class="nav-chevron h-4 w-4 text-slate-400 shrink-0"></i>
24
  </button>
 
30
  <NavLink class="nav-link-item nav-child-item"
31
  href="@MenuAuthorizationService.NormalizeMenuHref(sub.MenuUrl)"
32
  Match="@GetNavLinkMatch(sub.MenuUrl)">
33
+ @RenderSmallIcon(sub.Icon)
 
 
 
 
 
 
 
34
  <span>@sub.MenuName</span>
35
  </NavLink>
36
  }
 
39
  }
40
  else
41
  {
 
42
  <NavLink class="nav-link-item"
43
  href="@MenuAuthorizationService.NormalizeMenuHref(menu.MenuUrl)"
44
  Match="@(string.IsNullOrWhiteSpace(menu.MenuUrl) || menu.MenuUrl.Trim('/') is "dashboard" or "" ? NavLinkMatch.All : NavLinkMatch.Prefix)">
45
+ @RenderIcon(menu.Icon)
 
 
 
 
 
 
 
46
  <span>@menu.MenuName</span>
47
  </NavLink>
48
  }
49
  }
50
  }
51
+ else
52
  {
53
+ <div class="px-4 py-6 text-xs text-slate-400">
54
+ No menu items are available for this role.
55
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
  </nav>
58
 
59
  @code {
60
  private List<MenuDto> menus = new();
 
 
61
  private string currentPath = string.Empty;
62
  private HashSet<string> expandedMenuGroups = new();
 
63
 
64
  protected override async Task OnInitializedAsync()
65
  {
 
73
 
74
  if (user.Identity?.IsAuthenticated == true)
75
  {
 
 
 
 
 
 
 
 
 
 
 
 
76
  menus = await MenuAuthorization.GetUserMenusAsync(user);
77
+ BuildUrlHierarchy(menus);
 
 
 
 
 
78
 
79
+ foreach (var menu in menus)
80
+ {
81
+ if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus))
82
  {
83
+ expandedMenuGroups.Add(menu.MenuCode);
 
 
 
84
  }
85
  }
86
  }
 
95
  {
96
  currentPath = Navigation.ToBaseRelativePath(e.Location).Trim('/');
97
 
 
 
 
 
 
 
 
 
98
  foreach (var menu in menus)
99
  {
100
  if (menu.SubMenus != null && menu.SubMenus.Count > 0 && IsMenuExpanded(menu.MenuCode, menu.SubMenus))
 
106
  InvokeAsync(StateHasChanged);
107
  }
108
 
 
109
  private void ToggleMenuGroup(string menuCode)
110
  {
111
  if (!expandedMenuGroups.Remove(menuCode))
112
+ {
113
  expandedMenuGroups.Add(menuCode);
114
+ }
 
 
 
 
 
115
  }
116
 
117
  private bool IsMenuGroupExpanded(string menuCode) => expandedMenuGroups.Contains(menuCode);
 
118
 
119
  private bool IsMenuExpanded(string menuCode, IEnumerable<MenuDto> subMenus)
120
  {
 
123
  currentPath.StartsWith(s.MenuUrl.Trim('/'), StringComparison.OrdinalIgnoreCase));
124
  }
125
 
 
 
 
 
 
 
126
  private static NavLinkMatch GetNavLinkMatch(string? menuUrl)
127
  {
128
  return string.IsNullOrWhiteSpace(menuUrl) || menuUrl.Trim('/') is "dashboard" or ""
 
130
  : NavLinkMatch.Prefix;
131
  }
132
 
 
 
 
 
 
 
133
  private static void BuildUrlHierarchy(List<MenuDto> menuList)
134
  {
135
  var toRemove = new List<MenuDto>();
 
137
  foreach (var parent in menuList)
138
  {
139
  if (string.IsNullOrWhiteSpace(parent.MenuUrl))
140
+ {
141
  continue;
142
+ }
143
 
 
144
  if (parent.SubMenus != null && parent.SubMenus.Count > 0)
145
+ {
146
  continue;
147
+ }
148
 
149
  var parentPath = parent.MenuUrl.Trim('/');
150
  if (string.IsNullOrEmpty(parentPath))
151
+ {
152
  continue;
153
+ }
154
 
 
155
  var children = menuList
156
  .Where(m => m != parent
157
  && !string.IsNullOrWhiteSpace(m.MenuUrl)
 
170
  {
171
  menuList.Remove(item);
172
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  }
174
 
175
+ private static RenderFragment RenderIcon(string? icon) => builder =>
176
  {
177
+ builder.OpenElement(0, "i");
178
+ builder.AddAttribute(1, "data-lucide", string.IsNullOrWhiteSpace(icon) ? "circle" : icon);
179
+ builder.AddAttribute(2, "class", "h-4 w-4 mr-3 text-slate-400 shrink-0");
180
+ builder.CloseElement();
181
+ };
 
 
 
 
182
 
183
+ private static RenderFragment RenderSmallIcon(string? icon) => builder =>
184
+ {
185
+ builder.OpenElement(0, "i");
186
+ builder.AddAttribute(1, "data-lucide", string.IsNullOrWhiteSpace(icon) ? "circle" : icon);
187
+ builder.AddAttribute(2, "class", "h-3.5 w-3.5 mr-3 text-slate-400 shrink-0");
188
+ builder.CloseElement();
189
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  protected override async Task OnAfterRenderAsync(bool firstRender)
192
  {
 
196
  }
197
  catch
198
  {
 
199
  }
200
  }
201
 
 
203
  {
204
  Navigation.LocationChanged -= OnLocationChanged;
205
  }
 
 
 
 
 
 
 
206
  }
TaskTrackingSystem.WebApp/Components/Pages/Login.razor CHANGED
@@ -19,6 +19,9 @@
19
  Or
20
  <a href="/register" class="font-medium text-slate-900 hover:underline">register a new account</a>
21
  </p>
 
 
 
22
  </div>
23
 
24
  <form method="post" action="/account/login" class="mt-8 space-y-6">
@@ -68,6 +71,12 @@
68
  <p class="text-sm text-emerald-700 text-center">You have been signed out successfully.</p>
69
  </div>
70
  }
 
 
 
 
 
 
71
 
72
  <div>
73
  <button type="submit" class="group relative flex w-full justify-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
@@ -88,9 +97,13 @@
88
  [SupplyParameterFromQuery(Name = "loggedOut")]
89
  private string? LoggedOut { get; set; }
90
 
 
 
 
91
  private bool ShowError => string.Equals(Error, "invalid", StringComparison.OrdinalIgnoreCase);
92
  private bool ShowUnavailableError => string.Equals(Error, "unavailable", StringComparison.OrdinalIgnoreCase);
93
  private bool ShowLoggedOutMessage => string.Equals(LoggedOut, "true", StringComparison.OrdinalIgnoreCase);
 
94
 
95
  protected override async Task OnAfterRenderAsync(bool firstRender)
96
  {
 
19
  Or
20
  <a href="/register" class="font-medium text-slate-900 hover:underline">register a new account</a>
21
  </p>
22
+ <p class="mt-2 text-center text-sm text-slate-600">
23
+ <a href="/reset-password" class="font-medium text-violet-600 hover:underline">reset your password</a>
24
+ </p>
25
  </div>
26
 
27
  <form method="post" action="/account/login" class="mt-8 space-y-6">
 
71
  <p class="text-sm text-emerald-700 text-center">You have been signed out successfully.</p>
72
  </div>
73
  }
74
+ else if (ShowResetMessage)
75
+ {
76
+ <div class="p-3 rounded-lg bg-emerald-50 border border-emerald-100">
77
+ <p class="text-sm text-emerald-700 text-center">Password updated successfully. Please sign in with your new password.</p>
78
+ </div>
79
+ }
80
 
81
  <div>
82
  <button type="submit" class="group relative flex w-full justify-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
 
97
  [SupplyParameterFromQuery(Name = "loggedOut")]
98
  private string? LoggedOut { get; set; }
99
 
100
+ [SupplyParameterFromQuery(Name = "reset")]
101
+ private string? Reset { get; set; }
102
+
103
  private bool ShowError => string.Equals(Error, "invalid", StringComparison.OrdinalIgnoreCase);
104
  private bool ShowUnavailableError => string.Equals(Error, "unavailable", StringComparison.OrdinalIgnoreCase);
105
  private bool ShowLoggedOutMessage => string.Equals(LoggedOut, "true", StringComparison.OrdinalIgnoreCase);
106
+ private bool ShowResetMessage => string.Equals(Reset, "true", StringComparison.OrdinalIgnoreCase);
107
 
108
  protected override async Task OnAfterRenderAsync(bool firstRender)
109
  {
TaskTrackingSystem.WebApp/Components/Pages/Projects.razor CHANGED
@@ -391,23 +391,7 @@
391
  var client = ApiClient.CreateClient();
392
  try
393
  {
394
- var all = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
395
- if (!isAdmin && currentUserId > 0)
396
- {
397
- // Non-admin: only show projects they're a member of
398
- var memberProjectIds = new HashSet<long>();
399
- foreach (var p in all)
400
- {
401
- var membersResult = await client.GetFromJsonAsync<Result<IEnumerable<UserDto>>>($"Project/{p.Id}/members", Serialization.CaseInsensitive);
402
- if (membersResult?.Value?.Any(m => m.Id == currentUserId) == true)
403
- memberProjectIds.Add(p.Id);
404
- }
405
- projects = all.Where(p => memberProjectIds.Contains(p.Id)).ToList();
406
- }
407
- else
408
- {
409
- projects = all;
410
- }
411
  }
412
  catch (Exception ex)
413
  {
 
391
  var client = ApiClient.CreateClient();
392
  try
393
  {
394
+ projects = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  }
396
  catch (Exception ex)
397
  {
TaskTrackingSystem.WebApp/Components/Pages/Reports.razor DELETED
@@ -1,292 +0,0 @@
1
- @page "/reports"
2
- @using Microsoft.AspNetCore.Authorization
3
- @rendermode @(new InteractiveServerRenderMode(prerender: false))
4
- @attribute [Microsoft.AspNetCore.Authorization.Authorize]
5
- @inject ApiClientService ApiClient
6
- @inject IJSRuntime JS
7
- @inject AuthenticationStateProvider AuthStateProvider
8
- @inject MenuAuthorizationService MenuAuthorization
9
- @inject NavigationManager Navigation
10
-
11
- <PageTitle>Reports</PageTitle>
12
-
13
- <div class="space-y-8">
14
- <!-- Page Header -->
15
- <div>
16
- <h2 class="text-3xl font-bold tracking-tight text-slate-900">Reports</h2>
17
- <p class="text-slate-500 mt-1">Analytics, task reports, and user productivity.</p>
18
- </div>
19
-
20
- <!-- Task Report Section -->
21
- <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
22
- <div class="px-6 py-4 border-b border-slate-100">
23
- <h3 class="font-semibold text-lg text-slate-900">Task Report</h3>
24
- </div>
25
-
26
- <!-- Filters -->
27
- <div class="px-6 py-4 bg-slate-50/50 border-b border-slate-100">
28
- <div class="flex flex-wrap items-end gap-4">
29
- <div>
30
- <label class="block text-xs font-medium text-slate-500 mb-1">Start Date</label>
31
- <input @bind="filterStartDate" type="date" class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" />
32
- </div>
33
- <div>
34
- <label class="block text-xs font-medium text-slate-500 mb-1">End Date</label>
35
- <input @bind="filterEndDate" type="date" class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" />
36
- </div>
37
- <div>
38
- <label class="block text-xs font-medium text-slate-500 mb-1">Status</label>
39
- <select @bind="filterStatus" class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all">
40
- <option value="">All</option>
41
- <option value="1">To Do</option>
42
- <option value="2">In Progress</option>
43
- <option value="3">Done</option>
44
- </select>
45
- </div>
46
- <div>
47
- <label class="block text-xs font-medium text-slate-500 mb-1">Project</label>
48
- <select @bind="filterProjectId" class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all">
49
- <option value="0">All Projects</option>
50
- @foreach (var p in projects)
51
- {
52
- <option value="@p.Id">@p.Name</option>
53
- }
54
- </select>
55
- </div>
56
- <button @onclick="LoadTaskReport" class="inline-flex items-center rounded-lg bg-violet-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors">
57
- <i data-lucide="search" class="h-3.5 w-3.5 mr-1.5"></i>
58
- Search
59
- </button>
60
- <button @onclick="ResetFilters" class="inline-flex items-center rounded-lg border border-slate-200 bg-white px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors">
61
- <i data-lucide="rotate-ccw" class="h-3.5 w-3.5 mr-1.5 text-slate-500"></i>
62
- Reset
63
- </button>
64
- </div>
65
- </div>
66
-
67
- <!-- Task Report Table -->
68
- <div class="overflow-x-auto">
69
- @if (isLoadingTasks)
70
- {
71
- <div class="flex items-center justify-center py-12">
72
- <svg class="animate-spin h-5 w-5 text-slate-400" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
73
- </div>
74
- }
75
- else
76
- {
77
- <table class="w-full">
78
- <thead>
79
- <tr class="border-b border-slate-100 bg-slate-50/60">
80
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Task</th>
81
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Project</th>
82
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
83
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Priority</th>
84
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Assigned To</th>
85
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Due Date</th>
86
- </tr>
87
- </thead>
88
- <tbody class="divide-y divide-slate-100">
89
- @if (taskReports.Any())
90
- {
91
- @foreach (var t in taskReports)
92
- {
93
- <tr class="hover:bg-slate-50/50 transition-colors">
94
- <td class="px-6 py-3 text-sm font-medium text-slate-900">@t.Title</td>
95
- <td class="px-6 py-3 text-sm text-slate-500">@t.ProjectName</td>
96
- <td class="px-6 py-3">
97
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetStatusBadge(t.StatusId)">@t.StatusName</span>
98
- </td>
99
- <td class="px-6 py-3">
100
- <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium @GetPriorityBadge(t.PriorityId)">@t.PriorityName</span>
101
- </td>
102
- <td class="px-6 py-3 text-sm text-slate-500">@(t.AssignedToUser ?? "Unassigned")</td>
103
- <td class="px-6 py-3 text-sm text-slate-500">@t.DueDate.ToString("dd-MMM-yyyy")</td>
104
- </tr>
105
- }
106
- }
107
- else
108
- {
109
- <tr>
110
- <td colspan="6" class="px-6 py-12 text-center">
111
- <i data-lucide="file-search" class="h-10 w-10 mx-auto text-slate-300 mb-3"></i>
112
- <p class="text-sm text-slate-400">No tasks found. Adjust filters and try again.</p>
113
- </td>
114
- </tr>
115
- }
116
- </tbody>
117
- </table>
118
- }
119
- </div>
120
- </div>
121
-
122
- <!-- User Productivity Section -->
123
- <div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
124
- <div class="px-6 py-4 border-b border-slate-100">
125
- <h3 class="font-semibold text-lg text-slate-900">User Productivity</h3>
126
- </div>
127
-
128
- @if (isLoadingProductivity)
129
- {
130
- <div class="flex items-center justify-center py-12">
131
- <svg class="animate-spin h-5 w-5 text-slate-400" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
132
- </div>
133
- }
134
- else
135
- {
136
- <table class="w-full">
137
- <thead>
138
- <tr class="border-b border-slate-100 bg-slate-50/60">
139
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">User</th>
140
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Total Assigned</th>
141
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Completed</th>
142
- <th class="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Efficiency</th>
143
- </tr>
144
- </thead>
145
- <tbody class="divide-y divide-slate-100">
146
- @if (productivityList.Any())
147
- {
148
- @foreach (var u in productivityList)
149
- {
150
- <tr class="hover:bg-slate-50/50 transition-colors">
151
- <td class="px-6 py-4">
152
- <div class="flex items-center space-x-3">
153
- <span class="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-slate-700 font-semibold text-xs">
154
- @(u.FullName.Length > 0 ? u.FullName[0].ToString().ToUpper() : "U")
155
- </span>
156
- <div>
157
- <p class="text-sm font-semibold text-slate-900">@u.FullName</p>
158
- <p class="text-xs text-slate-400">@@@u.Username</p>
159
- </div>
160
- </div>
161
- </td>
162
- <td class="px-6 py-4 text-sm font-medium text-slate-900">@u.TotalAssignedTasks</td>
163
- <td class="px-6 py-4 text-sm font-medium text-emerald-600">@u.CompletedTasksCount</td>
164
- <td class="px-6 py-4">
165
- <div class="flex items-center space-x-3">
166
- <div class="flex-1 h-2 rounded-full bg-slate-100 overflow-hidden max-w-[120px]">
167
- <div class="h-full rounded-full bg-slate-900 transition-all" style="width: @(Math.Min(u.EfficiencyRatio, 100))%"></div>
168
- </div>
169
- <span class="text-sm font-medium text-slate-700">@u.EfficiencyRatio.ToString("F0")%</span>
170
- </div>
171
- </td>
172
- </tr>
173
- }
174
- }
175
- else
176
- {
177
- <tr>
178
- <td colspan="4" class="px-6 py-12 text-center">
179
- <p class="text-sm text-slate-400">No productivity data available.</p>
180
- </td>
181
- </tr>
182
- }
183
- </tbody>
184
- </table>
185
- }
186
- </div>
187
- </div>
188
-
189
- @code {
190
- private bool isLoadingTasks = true;
191
- private bool isLoadingProductivity = true;
192
- private List<TaskReportDto> taskReports = new();
193
- private List<UserProductivityDto> productivityList = new();
194
- private List<ProjectDto> projects = new();
195
-
196
- // Filters
197
- private DateTime? filterStartDate;
198
- private DateTime? filterEndDate;
199
- private string? filterStatus;
200
- private int filterProjectId;
201
-
202
- protected override async Task OnInitializedAsync()
203
- {
204
- var client = ApiClient.CreateClient();
205
- try
206
- {
207
- projects = await client.GetFromJsonAsync<List<ProjectDto>>("Project", Serialization.CaseInsensitive) ?? new();
208
- }
209
- catch { }
210
-
211
- await System.Threading.Tasks.Task.WhenAll(LoadTaskReport(), LoadProductivity());
212
- }
213
-
214
- protected override async Task OnAfterRenderAsync(bool firstRender)
215
- {
216
- await JS.InvokeVoidAsync("initIcons");
217
- }
218
-
219
- private async Task LoadTaskReport()
220
- {
221
- isLoadingTasks = true;
222
- StateHasChanged();
223
-
224
- var client = ApiClient.CreateClient();
225
- try
226
- {
227
- var query = "Report/tasks?";
228
- if (filterStartDate.HasValue) query += $"startDate={filterStartDate:yyyy-MM-dd}&";
229
- if (filterEndDate.HasValue) query += $"endDate={filterEndDate:yyyy-MM-dd}&";
230
- if (!string.IsNullOrEmpty(filterStatus)) query += $"status={filterStatus}&";
231
- if (filterProjectId > 0) query += $"projectId={filterProjectId}&";
232
-
233
- var result = await client.GetFromJsonAsync<Result<List<TaskReportDto>>>(query.TrimEnd('&', '?'), Serialization.CaseInsensitive);
234
- if (result?.IsSuccess == true && result.Value != null)
235
- taskReports = result.Value;
236
- }
237
- catch (Exception ex)
238
- {
239
- Console.WriteLine($"Task report error: {ex.Message}");
240
- }
241
- finally
242
- {
243
- isLoadingTasks = false;
244
- StateHasChanged();
245
- }
246
- }
247
-
248
- private async Task LoadProductivity()
249
- {
250
- isLoadingProductivity = true;
251
- var client = ApiClient.CreateClient();
252
- try
253
- {
254
- var result = await client.GetFromJsonAsync<Result<List<UserProductivityDto>>>("Report/user-productivity", Serialization.CaseInsensitive);
255
- if (result?.IsSuccess == true && result.Value != null)
256
- productivityList = result.Value;
257
- }
258
- catch (Exception ex)
259
- {
260
- Console.WriteLine($"Productivity error: {ex.Message}");
261
- }
262
- finally
263
- {
264
- isLoadingProductivity = false;
265
- }
266
- }
267
-
268
- private async Task ResetFilters()
269
- {
270
- filterStartDate = null;
271
- filterEndDate = null;
272
- filterStatus = null;
273
- filterProjectId = 0;
274
- await LoadTaskReport();
275
- }
276
-
277
- private string GetStatusBadge(long statusId) => statusId switch
278
- {
279
- 1 => "bg-slate-100 text-slate-700",
280
- 2 => "bg-blue-100 text-blue-700",
281
- 3 => "bg-emerald-100 text-emerald-700",
282
- _ => "bg-slate-100 text-slate-600"
283
- };
284
-
285
- private string GetPriorityBadge(long priorityId) => priorityId switch
286
- {
287
- 1 => "bg-slate-100 text-slate-600",
288
- 2 => "bg-amber-100 text-amber-700",
289
- 3 => "bg-red-100 text-red-700",
290
- _ => "bg-slate-100 text-slate-600"
291
- };
292
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
TaskTrackingSystem.WebApp/Components/Pages/ResetPassword.razor ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @page "/reset-password"
2
+ @layout Layout.EmptyLayout
3
+ @rendermode @(new InteractiveServerRenderMode(prerender: false))
4
+ @attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
5
+ @inject NavigationManager Navigation
6
+
7
+ <PageTitle>Reset Password - TaskFlow</PageTitle>
8
+
9
+ <div class="flex min-h-screen items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
10
+ <div class="w-full max-w-md space-y-8 bg-white p-8 rounded-2xl border border-slate-200 shadow-xl">
11
+ <div>
12
+ <div class="flex justify-center">
13
+ <span class="flex h-12 w-12 items-center justify-center rounded-xl bg-violet-600 text-white font-bold text-xl">T</span>
14
+ </div>
15
+ <h2 class="mt-6 text-center text-3xl font-extrabold text-slate-900">Reset your password</h2>
16
+ <p class="mt-2 text-center text-sm text-slate-600">
17
+ Use the recovery code configured in the API to set a new password.
18
+ </p>
19
+ </div>
20
+
21
+ <form method="post" action="/account/reset-password" class="mt-8 space-y-6">
22
+ <AntiforgeryToken />
23
+
24
+ <div class="space-y-4 rounded-md shadow-sm">
25
+ <div>
26
+ <label for="usernameOrEmail" class="block text-sm font-medium text-slate-700 mb-1">Username or Email</label>
27
+ <input id="usernameOrEmail" name="UsernameOrEmail" required placeholder="Enter username or email"
28
+ class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" />
29
+ </div>
30
+ <div>
31
+ <label for="recoveryCode" class="block text-sm font-medium text-slate-700 mb-1">Recovery Code</label>
32
+ <input id="recoveryCode" name="RecoveryCode" required placeholder="Enter recovery code"
33
+ class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" />
34
+ </div>
35
+ <div>
36
+ <label for="newPassword" class="block text-sm font-medium text-slate-700 mb-1">New Password</label>
37
+ <input id="newPassword" name="NewPassword" type="password" required placeholder="Min 8 characters"
38
+ class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all" />
39
+ </div>
40
+ </div>
41
+
42
+ @if (ShowError)
43
+ {
44
+ <div class="p-3 rounded-lg bg-red-50 border border-red-100">
45
+ <p class="text-sm text-red-600 text-center">@ErrorMessage</p>
46
+ </div>
47
+ }
48
+
49
+ <div class="flex items-center justify-between">
50
+ <a href="/login" class="text-sm font-medium text-violet-600 hover:underline">Back to sign in</a>
51
+ <button type="submit" class="group relative flex justify-center rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-violet-700 transition-colors shadow-sm">
52
+ Reset Password
53
+ </button>
54
+ </div>
55
+ </form>
56
+ </div>
57
+ </div>
58
+
59
+ @code {
60
+ [SupplyParameterFromQuery(Name = "error")]
61
+ private string? Error { get; set; }
62
+
63
+ private bool ShowError => !string.IsNullOrWhiteSpace(Error);
64
+ private string ErrorMessage => string.IsNullOrWhiteSpace(Error) ? "Unable to reset password." : Error;
65
+ }
TaskTrackingSystem.WebApp/MenuAuthorizationService.cs CHANGED
@@ -11,16 +11,8 @@ public class MenuAuthorizationService
11
  string.Empty,
12
  "login",
13
  "register",
14
- "error"
15
- };
16
-
17
- private static readonly HashSet<string> MemberRouteSegments = new(StringComparer.OrdinalIgnoreCase)
18
- {
19
- string.Empty,
20
- "dashboard",
21
- "home",
22
- "projects",
23
- "tasks"
24
  };
25
 
26
  private readonly ApiClientService _apiClient;
@@ -71,14 +63,14 @@ public class MenuAuthorizationService
71
  return true;
72
  }
73
 
74
- var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "Member";
75
 
76
- if (_sessionState.CachedMenuRole == role && _sessionState.CachedMenus != null)
77
  {
78
  return IsRouteAllowed(_sessionState.CachedMenus, relativePath);
79
  }
80
 
81
- return IsRouteAllowedByRole(role, relativePath);
82
  }
83
 
84
  public bool IsRouteAllowed(IReadOnlyList<MenuDto> menus, string relativePath)
@@ -106,14 +98,14 @@ public class MenuAuthorizationService
106
  return Task.FromResult(new List<MenuDto>());
107
  }
108
 
109
- var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "Member";
110
 
111
- if (_sessionState.CachedMenuRole == role && _sessionState.CachedMenus != null)
112
  {
113
  return Task.FromResult(_sessionState.CachedMenus);
114
  }
115
 
116
- return LoadMenusAsync(user, role);
117
  }
118
 
119
  public void PreloadMenus(ClaimsPrincipal user)
@@ -123,27 +115,27 @@ public class MenuAuthorizationService
123
  return;
124
  }
125
 
126
- var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "Member";
127
- if (_sessionState.CachedMenuRole == role && _sessionState.CachedMenus != null)
128
  {
129
  return;
130
  }
131
 
132
- _ = LoadMenusAsync(user, role);
133
  }
134
 
135
- private Task<List<MenuDto>> LoadMenusAsync(ClaimsPrincipal user, string role)
136
  {
137
  if (_loadingMenus is { IsCompleted: false })
138
  {
139
  return _loadingMenus;
140
  }
141
 
142
- _loadingMenus = FetchMenusFromApiAsync(user, role);
143
  return _loadingMenus;
144
  }
145
 
146
- private async Task<List<MenuDto>> FetchMenusFromApiAsync(ClaimsPrincipal user, string role)
147
  {
148
  try
149
  {
@@ -159,14 +151,22 @@ public class MenuAuthorizationService
159
  var menus = await response.Content.ReadFromJsonAsync<List<MenuDto>>(cancellationToken: cts.Token)
160
  ?? new List<MenuDto>();
161
 
 
 
 
 
 
162
  _sessionState.CachedMenus = menus;
163
- _sessionState.CachedMenuRole = role;
164
  return menus;
165
  }
166
  catch (Exception ex)
167
  {
168
  Console.WriteLine($"Menu API error: {ex.Message}");
169
- return new List<MenuDto>();
 
 
 
170
  }
171
  finally
172
  {
@@ -174,28 +174,6 @@ public class MenuAuthorizationService
174
  }
175
  }
176
 
177
- private static bool IsRouteAllowedByRole(string role, string relativePath)
178
- {
179
- var segment = GetFirstSegment(relativePath);
180
-
181
- if (PublicRouteSegments.Contains(segment))
182
- {
183
- return true;
184
- }
185
-
186
- if (role.Equals("Admin", StringComparison.OrdinalIgnoreCase))
187
- {
188
- return true;
189
- }
190
-
191
- if (role.Equals("Member", StringComparison.OrdinalIgnoreCase))
192
- {
193
- return MemberRouteSegments.Contains(segment);
194
- }
195
-
196
- return MemberRouteSegments.Contains(segment);
197
- }
198
-
199
  private static bool MenuCollectionMatchesSegment(IReadOnlyList<MenuDto> menus, string segment)
200
  {
201
  foreach (var menu in menus)
@@ -227,4 +205,65 @@ public class MenuAuthorizationService
227
  var menuSegment = menu.MenuUrl.Split('?')[0].Trim('/').Split('/')[0];
228
  return menuSegment.Equals(segment, StringComparison.OrdinalIgnoreCase);
229
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  }
 
11
  string.Empty,
12
  "login",
13
  "register",
14
+ "error",
15
+ "reset-password"
 
 
 
 
 
 
 
 
16
  };
17
 
18
  private readonly ApiClientService _apiClient;
 
63
  return true;
64
  }
65
 
66
+ var roleKey = GetRoleCacheKey(user);
67
 
68
+ if (_sessionState.CachedMenuRoleId == roleKey && _sessionState.CachedMenus != null)
69
  {
70
  return IsRouteAllowed(_sessionState.CachedMenus, relativePath);
71
  }
72
 
73
+ return false;
74
  }
75
 
76
  public bool IsRouteAllowed(IReadOnlyList<MenuDto> menus, string relativePath)
 
98
  return Task.FromResult(new List<MenuDto>());
99
  }
100
 
101
+ var roleKey = GetRoleCacheKey(user);
102
 
103
+ if (_sessionState.CachedMenuRoleId == roleKey && _sessionState.CachedMenus != null)
104
  {
105
  return Task.FromResult(_sessionState.CachedMenus);
106
  }
107
 
108
+ return LoadMenusAsync(user, roleKey);
109
  }
110
 
111
  public void PreloadMenus(ClaimsPrincipal user)
 
115
  return;
116
  }
117
 
118
+ var roleKey = GetRoleCacheKey(user);
119
+ if (_sessionState.CachedMenuRoleId == roleKey && _sessionState.CachedMenus != null)
120
  {
121
  return;
122
  }
123
 
124
+ _ = LoadMenusAsync(user, roleKey);
125
  }
126
 
127
+ private Task<List<MenuDto>> LoadMenusAsync(ClaimsPrincipal user, string roleKey)
128
  {
129
  if (_loadingMenus is { IsCompleted: false })
130
  {
131
  return _loadingMenus;
132
  }
133
 
134
+ _loadingMenus = FetchMenusFromApiAsync(user, roleKey);
135
  return _loadingMenus;
136
  }
137
 
138
+ private async Task<List<MenuDto>> FetchMenusFromApiAsync(ClaimsPrincipal user, string roleKey)
139
  {
140
  try
141
  {
 
151
  var menus = await response.Content.ReadFromJsonAsync<List<MenuDto>>(cancellationToken: cts.Token)
152
  ?? new List<MenuDto>();
153
 
154
+ if (menus.Count == 0)
155
+ {
156
+ menus = BuildFallbackMenus(user);
157
+ }
158
+
159
  _sessionState.CachedMenus = menus;
160
+ _sessionState.CachedMenuRoleId = roleKey;
161
  return menus;
162
  }
163
  catch (Exception ex)
164
  {
165
  Console.WriteLine($"Menu API error: {ex.Message}");
166
+ var fallbackMenus = BuildFallbackMenus(user);
167
+ _sessionState.CachedMenus = fallbackMenus;
168
+ _sessionState.CachedMenuRoleId = roleKey;
169
+ return fallbackMenus;
170
  }
171
  finally
172
  {
 
174
  }
175
  }
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  private static bool MenuCollectionMatchesSegment(IReadOnlyList<MenuDto> menus, string segment)
178
  {
179
  foreach (var menu in menus)
 
205
  var menuSegment = menu.MenuUrl.Split('?')[0].Trim('/').Split('/')[0];
206
  return menuSegment.Equals(segment, StringComparison.OrdinalIgnoreCase);
207
  }
208
+
209
+ private static string GetRoleCacheKey(ClaimsPrincipal user)
210
+ {
211
+ var roleId = user.FindFirst("role_id")?.Value;
212
+ if (!string.IsNullOrWhiteSpace(roleId))
213
+ {
214
+ return $"id:{roleId}";
215
+ }
216
+
217
+ var roleName = user.FindFirst(ClaimTypes.Role)?.Value;
218
+ if (!string.IsNullOrWhiteSpace(roleName))
219
+ {
220
+ return $"name:{roleName}";
221
+ }
222
+
223
+ return string.Empty;
224
+ }
225
+
226
+ private static List<MenuDto> BuildFallbackMenus(ClaimsPrincipal user)
227
+ {
228
+ var roleName = user.FindFirst(ClaimTypes.Role)?.Value ?? string.Empty;
229
+
230
+ var menus = new List<MenuDto>
231
+ {
232
+ CreateMenu("dashboard", "Dashboard", "/dashboard", 1, "layout-dashboard")
233
+ };
234
+
235
+ if (roleName.Equals("Employee", StringComparison.OrdinalIgnoreCase))
236
+ {
237
+ menus.Add(CreateMenu("tasks", "Tasks", "/tasks", 2, "list-checks"));
238
+ return menus;
239
+ }
240
+
241
+ menus.Add(CreateMenu("tasks", "Tasks", "/tasks", 2, "list-checks"));
242
+ menus.Add(CreateMenu("projects", "Projects", "/projects", 3, "folder-kanban"));
243
+ menus.Add(CreateMenu("reports", "Reports", "/reports/tasks", 4, "bar-chart-3"));
244
+
245
+ if (roleName.Equals("Manager", StringComparison.OrdinalIgnoreCase) ||
246
+ roleName.Equals("Admin", StringComparison.OrdinalIgnoreCase))
247
+ {
248
+ if (roleName.Equals("Admin", StringComparison.OrdinalIgnoreCase))
249
+ {
250
+ menus.Add(CreateMenu("users", "Users", "/users", 5, "users"));
251
+ menus.Add(CreateMenu("roles", "Roles", "/roles", 6, "shield"));
252
+ }
253
+ }
254
+
255
+ return menus;
256
+ }
257
+
258
+ private static MenuDto CreateMenu(string code, string name, string url, int orderNo, string icon)
259
+ {
260
+ return new MenuDto
261
+ {
262
+ MenuCode = code,
263
+ MenuName = name,
264
+ MenuUrl = url,
265
+ OrderNo = orderNo,
266
+ Icon = icon
267
+ };
268
+ }
269
  }
TaskTrackingSystem.WebApp/UserSessionState.cs CHANGED
@@ -8,12 +8,12 @@ public class UserSessionState
8
  public string? Token { get; set; }
9
  public ClaimsPrincipal? CachedUser { get; set; }
10
 
11
- public string? CachedMenuRole { get; set; }
12
  public List<MenuDto>? CachedMenus { get; set; }
13
 
14
  public void ClearMenuCache()
15
  {
16
- CachedMenuRole = null;
17
  CachedMenus = null;
18
  }
19
 
 
8
  public string? Token { get; set; }
9
  public ClaimsPrincipal? CachedUser { get; set; }
10
 
11
+ public string? CachedMenuRoleId { get; set; }
12
  public List<MenuDto>? CachedMenus { get; set; }
13
 
14
  public void ClearMenuCache()
15
  {
16
+ CachedMenuRoleId = null;
17
  CachedMenus = null;
18
  }
19