305 lines
13 KiB
C#
305 lines
13 KiB
C#
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Data;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
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<ApplicationUser> _users;
|
|
private readonly RoleManager<IdentityRole> _roles;
|
|
private readonly IAppEmailSender _email;
|
|
private readonly ExternalOrigin _externalOrigin;
|
|
private readonly ILogger<UsersController> _logger;
|
|
private readonly AccountDeletionService? _deletions;
|
|
private readonly JobTrackerContext? _db;
|
|
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null, JobTrackerContext? db = null)
|
|
{
|
|
_users = users;
|
|
_roles = roles;
|
|
_email = email;
|
|
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
|
|
_logger = logger;
|
|
_deletions = deletions;
|
|
_db = db;
|
|
}
|
|
|
|
public sealed record UserDto(
|
|
string Id,
|
|
string? Email,
|
|
string? UserName,
|
|
string? FirstName,
|
|
string? LastName,
|
|
string? DisplayName,
|
|
bool EmailConfirmed,
|
|
string? GoogleEmail,
|
|
DateTimeOffset? GoogleLinkedAt,
|
|
List<string> Roles,
|
|
bool IsCurrentUser,
|
|
bool CanRemoveAdmin);
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<List<UserDto>>> List(CancellationToken cancellationToken)
|
|
{
|
|
if (_db is not null)
|
|
{
|
|
var users = await _db.Users.AsNoTracking()
|
|
.OrderBy(user => user.Email)
|
|
.ToListAsync(cancellationToken);
|
|
var roleRows = await (
|
|
from userRole in _db.UserRoles.AsNoTracking()
|
|
join role in _db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
|
select new { userRole.UserId, role.Name })
|
|
.ToListAsync(cancellationToken);
|
|
var rolesByUser = roleRows
|
|
.Where(row => !string.IsNullOrWhiteSpace(row.Name))
|
|
.GroupBy(row => row.UserId)
|
|
.ToDictionary(group => group.Key, group => group.Select(row => row.Name!).ToList());
|
|
var relationalAdminCount = roleRows
|
|
.Where(row => string.Equals(row.Name, "Admin", StringComparison.OrdinalIgnoreCase))
|
|
.Select(row => row.UserId)
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Count();
|
|
var relationalCurrentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
|
|
|
return Ok(users.Select(user =>
|
|
{
|
|
var roles = rolesByUser.GetValueOrDefault(user.Id) ?? [];
|
|
return ToDto(user, roles, relationalCurrentUserId, !roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) || relationalAdminCount > 1);
|
|
}).ToList());
|
|
}
|
|
|
|
// Retained only for isolated controller tests/manual construction. The application DI path
|
|
// always supplies JobTrackerContext and uses the fixed two-query projection above.
|
|
var items = await _users.Users
|
|
.OrderBy(u => u.Email)
|
|
.ToListAsync(cancellationToken);
|
|
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
|
var adminCount = (await _users.GetUsersInRoleAsync("Admin")).Count;
|
|
|
|
var outList = new List<UserDto>(items.Count);
|
|
foreach (var u in items)
|
|
{
|
|
var rs = await _users.GetRolesAsync(u);
|
|
var roles = rs.ToList();
|
|
outList.Add(ToDto(u, roles, currentUserId, !roles.Contains("Admin", StringComparer.OrdinalIgnoreCase) || adminCount > 1));
|
|
}
|
|
|
|
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<ActionResult<UserDto>> 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<string>()).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);
|
|
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
|
return Ok(ToDto(u, rs.ToList(), currentUserId, true));
|
|
}
|
|
|
|
public sealed record SetRolesRequest(string[] Roles);
|
|
|
|
[HttpPut("{id}/roles")]
|
|
public async Task<IActionResult> 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<string>()).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.Contains("Admin", StringComparer.OrdinalIgnoreCase)
|
|
&& (await _users.GetUsersInRoleAsync("Admin")).Count <= 1)
|
|
{
|
|
return Conflict(new ProblemDetails
|
|
{
|
|
Title = "Last administrator protected",
|
|
Detail = "Assign the Admin role to another user before removing it from the final administrator."
|
|
});
|
|
}
|
|
|
|
foreach (var r in toAdd)
|
|
{
|
|
if (!await _roles.RoleExistsAsync(r))
|
|
{
|
|
var createRole = await _roles.CreateAsync(new IdentityRole(r));
|
|
if (!createRole.Succeeded) return IdentityFailure(createRole);
|
|
}
|
|
|
|
var addRole = await _users.AddToRoleAsync(u, r);
|
|
if (!addRole.Succeeded) return IdentityFailure(addRole);
|
|
}
|
|
|
|
if (toRemove.Count > 0)
|
|
{
|
|
var removeRoles = await _users.RemoveFromRolesAsync(u, toRemove);
|
|
if (!removeRoles.Succeeded) return IdentityFailure(removeRoles);
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpDelete("{id}")]
|
|
public async Task<IActionResult> Delete([FromRoute] string id, CancellationToken cancellationToken)
|
|
{
|
|
var u = await _users.FindByIdAsync(id);
|
|
if (u is null) return NotFound();
|
|
|
|
if (await _users.IsInRoleAsync(u, "Admin")
|
|
&& (await _users.GetUsersInRoleAsync("Admin")).Count <= 1)
|
|
{
|
|
return Conflict(new ProblemDetails
|
|
{
|
|
Title = "Last administrator protected",
|
|
Detail = "Assign the Admin role to another user before deleting the final administrator."
|
|
});
|
|
}
|
|
|
|
if (_deletions is null || !_deletions.CanAcceptRequests)
|
|
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved.");
|
|
if (!string.Equals(Request.Headers["X-Confirm-Account-Deletion"].ToString(), u.Email, StringComparison.OrdinalIgnoreCase))
|
|
return BadRequest("Confirm the exact account email before deletion.");
|
|
var requestedBy = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
|
if (string.IsNullOrWhiteSpace(requestedBy)) return Unauthorized();
|
|
var request = await _deletions.RequestAsync(u.Id, requestedBy, cancellationToken);
|
|
return request is null
|
|
? Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable")
|
|
: Accepted(new { requestId = request.RequestId, status = request.Status, stage = request.Stage });
|
|
}
|
|
|
|
[HttpPost("{id}/send-password-reset")]
|
|
[EnableRateLimiting("auth-email")]
|
|
public async Task<IActionResult> 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 link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}");
|
|
|
|
try
|
|
{
|
|
await _email.SendAsync(
|
|
u.Email,
|
|
"Password reset",
|
|
$"An admin initiated a password reset for your Jobbjakt account.\n\nReset link:\n{link}\n",
|
|
cancellationToken
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send admin-initiated password reset email to {Email}", u.Email);
|
|
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Password reset email could not be sent right now. Please try again later.");
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
public sealed record SendTestEmailRequest(string? ToEmail, string? Subject, string? Message);
|
|
|
|
[HttpPost("send-test-email")]
|
|
[EnableRateLimiting("auth-email")]
|
|
public async Task<IActionResult> 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) ? "Jobbjakt test email" : request!.Subject!.Trim();
|
|
var message = string.IsNullOrWhiteSpace(request?.Message)
|
|
? "This is a test email from the Jobbjakt admin panel.\n\nIf you received this, the SMTP configuration is working."
|
|
: request!.Message!.Trim();
|
|
|
|
try
|
|
{
|
|
await _email.SendAsync(
|
|
toEmail,
|
|
subject,
|
|
$"{message}\n\nSent at: {DateTimeOffset.UtcNow:u}",
|
|
cancellationToken
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send test email to {Email}", toEmail);
|
|
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Test email could not be sent right now. Please try again later.");
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
private static UserDto ToDto(ApplicationUser user, List<string> roles, string? currentUserId, bool canRemoveAdmin)
|
|
{
|
|
return new UserDto(
|
|
user.Id,
|
|
user.Email,
|
|
user.UserName,
|
|
user.FirstName,
|
|
user.LastName,
|
|
user.DisplayName,
|
|
user.EmailConfirmed,
|
|
user.GoogleEmail,
|
|
user.GoogleLinkedAt,
|
|
roles,
|
|
string.Equals(user.Id, currentUserId, StringComparison.Ordinal),
|
|
canRemoveAdmin);
|
|
}
|
|
|
|
private BadRequestObjectResult IdentityFailure(IdentityResult result)
|
|
{
|
|
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
|
}
|
|
|
|
private static string? TrimOrNull(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
}
|