using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Security.Claims; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/users")] [Authorize(Roles = "Admin")] public sealed class UsersController : ControllerBase { private readonly UserManager _users; private readonly RoleManager _roles; private readonly IAppEmailSender _email; private readonly IConfiguration _cfg; public UsersController(UserManager users, RoleManager roles, IAppEmailSender email, IConfiguration cfg) { _users = users; _roles = roles; _email = email; _cfg = cfg; } public sealed record UserDto( string Id, string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, bool EmailConfirmed, string? GoogleEmail, DateTimeOffset? GoogleLinkedAt, List Roles); [HttpGet] public async Task>> List(CancellationToken cancellationToken) { var items = await _users.Users .OrderBy(u => u.Email) .ToListAsync(cancellationToken); var outList = new List(items.Count); foreach (var u in items) { var rs = await _users.GetRolesAsync(u); outList.Add(ToDto(u, rs.ToList())); } return Ok(outList); } public sealed record CreateUserRequest(string Email, string Password, string? UserName, string? FirstName, string? LastName, string? DisplayName, string[]? Roles); [HttpPost] public async Task> Create([FromBody] CreateUserRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? "").Trim(); var password = request.Password ?? ""; if (email.Length == 0) return BadRequest("Email is required."); if (password.Length == 0) return BadRequest("Password is required."); var existing = await _users.FindByEmailAsync(email); if (existing is not null) return BadRequest("User already exists."); var u = new ApplicationUser { UserName = string.IsNullOrWhiteSpace(request.UserName) ? email : request.UserName.Trim(), Email = email, EmailConfirmed = true, FirstName = TrimOrNull(request.FirstName), LastName = TrimOrNull(request.LastName), DisplayName = TrimOrNull(request.DisplayName) }; var res = await _users.CreateAsync(u, password); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); var roles = (request.Roles ?? Array.Empty()).Select(r => (r ?? "").Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); foreach (var r in roles) { if (!await _roles.RoleExistsAsync(r)) await _roles.CreateAsync(new IdentityRole(r)); await _users.AddToRoleAsync(u, r); } var rs = await _users.GetRolesAsync(u); return Ok(ToDto(u, rs.ToList())); } public sealed record SetRolesRequest(string[] Roles); [HttpPut("{id}/roles")] public async Task SetRoles([FromRoute] string id, [FromBody] SetRolesRequest request, CancellationToken cancellationToken) { var u = await _users.FindByIdAsync(id); if (u is null) return NotFound(); var desired = (request.Roles ?? Array.Empty()).Select(r => (r ?? "").Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var current = await _users.GetRolesAsync(u); var toRemove = current.Where(r => !desired.Contains(r, StringComparer.OrdinalIgnoreCase)).ToList(); var toAdd = desired.Where(r => !current.Contains(r, StringComparer.OrdinalIgnoreCase)).ToList(); if (toRemove.Count > 0) await _users.RemoveFromRolesAsync(u, toRemove); foreach (var r in toAdd) { if (!await _roles.RoleExistsAsync(r)) await _roles.CreateAsync(new IdentityRole(r)); await _users.AddToRoleAsync(u, r); } return NoContent(); } [HttpDelete("{id}")] public async Task Delete([FromRoute] string id, CancellationToken cancellationToken) { var u = await _users.FindByIdAsync(id); if (u is null) return NotFound(); var res = await _users.DeleteAsync(u); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); return NoContent(); } [HttpPost("{id}/send-password-reset")] public async Task SendPasswordReset([FromRoute] string id, CancellationToken cancellationToken) { var u = await _users.FindByIdAsync(id); if (u is null) return NotFound(); if (string.IsNullOrWhiteSpace(u.Email)) return BadRequest("User has no email."); var token = await _users.GeneratePasswordResetTokenAsync(u); var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { baseUrl = $"{Request.Scheme}://{Request.Host}"; } var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}"; await _email.SendAsync( u.Email, "Password reset", $"An admin initiated a password reset for your Job Tracker account.\n\nReset link:\n{link}\n", cancellationToken ); return NoContent(); } public sealed record SendTestEmailRequest(string? ToEmail, string? Subject, string? Message); [HttpPost("send-test-email")] public async Task SendTestEmail([FromBody] SendTestEmailRequest? request, CancellationToken cancellationToken) { var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); var currentUser = currentUserId is null ? null : await _users.FindByIdAsync(currentUserId); var toEmail = (request?.ToEmail ?? currentUser?.Email ?? "").Trim(); if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required."); var subject = string.IsNullOrWhiteSpace(request?.Subject) ? "Job Tracker test email" : request!.Subject!.Trim(); var message = string.IsNullOrWhiteSpace(request?.Message) ? "This is a test email from the Job Tracker admin panel.\n\nIf you received this, the SMTP configuration is working." : request!.Message!.Trim(); await _email.SendAsync( toEmail, subject, $"{message}\n\nSent at: {DateTimeOffset.UtcNow:u}", cancellationToken ); return NoContent(); } private static UserDto ToDto(ApplicationUser user, List roles) { return new UserDto( user.Id, user.Email, user.UserName, user.FirstName, user.LastName, user.DisplayName, user.EmailConfirmed, user.GoogleEmail, user.GoogleLinkedAt, roles); } private static string? TrimOrNull(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } }