Adds user management

This commit is contained in:
2026-06-22 21:14:22 +02:00
parent 5331be4279
commit 6976eb4337
40 changed files with 3392 additions and 146 deletions
@@ -4,3 +4,15 @@ public record CreateOwnerRequest(string Name, string Email, string Password);
public record LoginRequest(string Email, string Password);
public record InviteUserRequest(string Email, string Role);
public record CompleteSetupRequest(string Token, string Password);
public record ChangeRoleRequest(string NewRole);
public record SetUserActiveRequest(bool IsActive);
public record PendingInvitationInfo(Guid Id, string Email, string Role, string Token, DateTimeOffset CreatedAt);
public record UserDto(
Guid Id,
string Email,
string Name,
string Role,
bool IsActive,
DateTimeOffset CreatedAt,
bool InvitationPending,
string? InviteLink);
@@ -1,3 +1,5 @@
using SlpModularCms.Core.Identity.Models;
namespace SlpModularCms.Core.Identity.Services;
/// <summary>
@@ -19,4 +21,29 @@ public interface IInvitationService
/// Voltooit de uitnodiging door een gebruiker aan te maken met het opgegeven wachtwoord.
/// </summary>
Task CompleteInvitationAsync(string token, string password);
/// <summary>
/// Geeft de pending uitnodiging voor een e-mailadres terug, of (false, null) als er geen is.
/// </summary>
Task<(bool IsPending, string? Token)> GetPendingInvitationByEmailAsync(string email);
/// <summary>
/// Verwijdert alle pending (niet-geaccepteerde) uitnodigingen voor een e-mailadres.
/// </summary>
Task DeletePendingInvitationsByEmailAsync(string email);
/// <summary>
/// Geeft alle actieve (niet-geaccepteerde, niet-verlopen) uitnodigingen terug.
/// </summary>
Task<IReadOnlyList<PendingInvitationInfo>> GetAllPendingInvitationsAsync();
/// <summary>
/// Verwijdert een pending uitnodiging op basis van het ID. Retourneert false als niet gevonden.
/// </summary>
Task<bool> DeleteInvitationByIdAsync(Guid invitationId);
/// <summary>
/// Wijzigt de rol van een pending uitnodiging. Retourneert false als niet gevonden.
/// </summary>
Task<bool> UpdateInvitationRoleAsync(Guid invitationId, string newRole);
}
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
namespace SlpModularCms.Core.Identity.Services;
@@ -77,6 +78,59 @@ public class InvitationService : IInvitationService
await _context.SaveChangesAsync();
}
public async Task<(bool IsPending, string? Token)> GetPendingInvitationByEmailAsync(string email)
{
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Email == email && !i.IsAccepted && i.ExpiryDate > DateTimeOffset.UtcNow);
return invitation is null ? (false, null) : (true, invitation.Token);
}
public async Task DeletePendingInvitationsByEmailAsync(string email)
{
var invitations = await _context.Invitations
.Where(i => i.Email == email && !i.IsAccepted)
.ToListAsync();
if (invitations.Count > 0)
{
_context.Invitations.RemoveRange(invitations);
await _context.SaveChangesAsync();
}
}
public async Task<IReadOnlyList<PendingInvitationInfo>> GetAllPendingInvitationsAsync()
{
var invitations = await _context.Invitations
.Where(i => !i.IsAccepted && i.ExpiryDate > DateTimeOffset.UtcNow)
.OrderBy(i => i.CreatedAt)
.ToListAsync();
return invitations
.Select(i => new PendingInvitationInfo(i.Id, i.Email, i.Role, i.Token, i.CreatedAt))
.ToList();
}
public async Task<bool> DeleteInvitationByIdAsync(Guid invitationId)
{
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Id == invitationId && !i.IsAccepted);
if (invitation is null) return false;
_context.Invitations.Remove(invitation);
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> UpdateInvitationRoleAsync(Guid invitationId, string newRole)
{
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Id == invitationId && !i.IsAccepted);
if (invitation is null) return false;
invitation.Role = newRole;
await _context.SaveChangesAsync();
return true;
}
private static string GenerateSecureToken()
{
var bytes = new byte[32];
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Core.Data;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
namespace SlpModularCms.Modules.Identity.Controllers;
[ApiController]
[Route("[controller]")]
[AllowAnonymous]
public class InvitationController : ControllerBase
{
private readonly IInvitationService _invitationService;
private readonly ApplicationDbContext _context;
public InvitationController(IInvitationService invitationService, ApplicationDbContext context)
{
_invitationService = invitationService;
_context = context;
}
[HttpGet("validate")]
public async Task<IActionResult> Validate([FromQuery] string token)
{
if (string.IsNullOrWhiteSpace(token))
return Ok(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
var invitation = await _context.Invitations
.FirstOrDefaultAsync(i => i.Token == token);
if (invitation is null)
return Ok(new { isValid = false, email = (string?)null, name = (string?)null, errorCode = "NOT_FOUND" });
if (invitation.IsAccepted)
return Ok(new { isValid = false, email = invitation.Email, name = (string?)null, errorCode = "USED" });
if (invitation.IsExpired)
return Ok(new { isValid = false, email = invitation.Email, name = (string?)null, errorCode = "EXPIRED" });
return Ok(new { isValid = true, email = invitation.Email, name = (string?)null, errorCode = (string?)null });
}
[HttpPost("complete")]
public async Task<IActionResult> Complete([FromBody] CompleteSetupRequest request)
{
await _invitationService.CompleteInvitationAsync(request.Token, request.Password);
return Ok(new { message = "Account succesvol ingesteld. Je kunt nu inloggen." });
}
}
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Core.Identity.Entities;
using SlpModularCms.Core.Identity.Models;
using SlpModularCms.Core.Identity.Services;
@@ -7,38 +10,189 @@ namespace SlpModularCms.Modules.Identity.Controllers;
[ApiController]
[Route("[controller]")]
[Authorize(Policy = "AdminOnly")]
public class UsersController : ControllerBase
{
private readonly IInvitationService _invitationService;
private readonly UserManager<ApplicationUser> _userManager;
public UsersController(IInvitationService invitationService)
public UsersController(IInvitationService invitationService, UserManager<ApplicationUser> userManager)
{
_invitationService = invitationService;
_userManager = userManager;
}
[HttpGet]
public async Task<IActionResult> GetUsers()
{
var users = await _userManager.Users.OrderBy(u => u.CreatedAt).ToListAsync();
var userEmails = users
.Select(u => u.Email?.ToLowerInvariant())
.Where(e => e is not null)
.ToHashSet();
var result = new List<UserDto>(users.Count);
foreach (var user in users)
{
var roles = await _userManager.GetRolesAsync(user);
var role = roles.FirstOrDefault() ?? "User";
var (isPending, token) = await _invitationService.GetPendingInvitationByEmailAsync(user.Email ?? string.Empty);
var inviteLink = isPending && token is not null
? $"/invite/complete?token={Uri.EscapeDataString(token)}"
: null;
result.Add(new UserDto(
user.Id,
user.Email ?? string.Empty,
user.DisplayName ?? user.Email ?? string.Empty,
role,
user.IsActive,
user.CreatedAt,
isPending,
inviteLink));
}
// Include pending invitations that have no user account yet
var pendingInvitations = await _invitationService.GetAllPendingInvitationsAsync();
foreach (var inv in pendingInvitations)
{
if (!userEmails.Contains(inv.Email.ToLowerInvariant()))
{
result.Add(new UserDto(
inv.Id,
inv.Email,
inv.Email,
inv.Role,
true,
inv.CreatedAt,
true,
$"/invite/complete?token={Uri.EscapeDataString(inv.Token)}"));
}
}
return Ok(result.OrderBy(u => u.CreatedAt));
}
[HttpPost("invite")]
[Authorize(Policy = "AdminOnly")]
public async Task<IActionResult> Invite([FromBody] InviteUserRequest request)
{
var token = await _invitationService.CreateInvitationAsync(request.Email, request.Role);
// In een echte app zou je hier een email sturen. Voor nu geven we de link terug.
var inviteLink = $"/setup/complete?token={Uri.EscapeDataString(token)}";
return Ok(new { InviteLink = inviteLink });
var inviteLink = $"/invite/complete?token={Uri.EscapeDataString(token)}";
return Ok(new { inviteLink });
}
[HttpPost("complete-setup")]
[AllowAnonymous]
public async Task<IActionResult> CompleteSetup([FromBody] CompleteSetupRequest request)
[HttpPut("{userId:guid}/role")]
public async Task<IActionResult> ChangeRole(Guid userId, [FromBody] ChangeRoleRequest request)
{
await _invitationService.CompleteInvitationAsync(request.Token, request.Password);
return Ok(new { Message = "Account succesvol ingesteld. Je kunt nu inloggen." });
var target = await _userManager.FindByIdAsync(userId.ToString());
// Pending invite: no user account yet — update the invitation's role directly
if (target is null)
{
var requesterEmailForInvite = User.Identity?.Name;
var requesterForInvite = requesterEmailForInvite is not null
? await _userManager.FindByNameAsync(requesterEmailForInvite)
: null;
var requesterRolesForInvite = requesterForInvite is not null
? await _userManager.GetRolesAsync(requesterForInvite)
: [];
var requesterRoleForInvite = requesterRolesForInvite.FirstOrDefault() ?? "User";
// Admins cannot assign Owner role via invitation either
if (requesterRoleForInvite == "Administrator" && request.NewRole == "Owner")
return Forbid();
var updated = await _invitationService.UpdateInvitationRoleAsync(userId, request.NewRole);
return updated ? Ok() : NotFound();
}
var requesterEmail = User.Identity?.Name;
var requester = requesterEmail is not null ? await _userManager.FindByNameAsync(requesterEmail) : null;
var requesterRoles = requester is not null ? await _userManager.GetRolesAsync(requester) : [];
var requesterRole = requesterRoles.FirstOrDefault() ?? "User";
var targetRoles = await _userManager.GetRolesAsync(target);
var targetRole = targetRoles.FirstOrDefault() ?? "User";
if (requesterRole == "Administrator" && targetRole != "User")
return Forbid();
if (targetRole == "Owner" && request.NewRole != "Owner")
{
var ownerCount = (await _userManager.GetUsersInRoleAsync("Owner")).Count;
if (ownerCount <= 1)
return BadRequest(new { detail = "At least one Owner must remain." });
}
if (targetRoles.Count > 0)
await _userManager.RemoveFromRolesAsync(target, targetRoles);
await _userManager.AddToRoleAsync(target, request.NewRole);
return Ok();
}
[HttpGet("validate-invitation")]
[AllowAnonymous]
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
[HttpPut("{userId:guid}/active")]
public async Task<IActionResult> SetUserActive(Guid userId, [FromBody] SetUserActiveRequest request)
{
var isValid = await _invitationService.ValidateInvitationAsync(token);
return Ok(new { Valid = isValid });
var target = await _userManager.FindByIdAsync(userId.ToString());
if (target is null) return NotFound();
var requesterEmail = User.Identity?.Name;
if (string.Equals(target.Email, requesterEmail, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { detail = "You cannot change your own active status." });
if (!request.IsActive)
{
var targetRoles = await _userManager.GetRolesAsync(target);
if (targetRoles.Contains("Owner"))
{
var ownerCount = (await _userManager.GetUsersInRoleAsync("Owner")).Count;
if (ownerCount <= 1)
return BadRequest(new { detail = "At least one Owner must remain." });
}
}
target.IsActive = request.IsActive;
var result = await _userManager.UpdateAsync(target);
if (!result.Succeeded)
return StatusCode(500, new { detail = "Failed to update user." });
return Ok();
}
[HttpDelete("{userId:guid}")]
public async Task<IActionResult> DeleteUser(Guid userId)
{
var target = await _userManager.FindByIdAsync(userId.ToString());
// If no user account found, try to cancel a pending invitation with this ID
if (target is null)
{
var deleted = await _invitationService.DeleteInvitationByIdAsync(userId);
return deleted ? NoContent() : NotFound();
}
var requesterEmail = User.Identity?.Name;
if (string.Equals(target.Email, requesterEmail, StringComparison.OrdinalIgnoreCase))
return BadRequest(new { detail = "You cannot delete your own account." });
var targetRoles = await _userManager.GetRolesAsync(target);
if (targetRoles.Contains("Owner"))
{
var ownerCount = (await _userManager.GetUsersInRoleAsync("Owner")).Count;
if (ownerCount <= 1)
return BadRequest(new { detail = "At least one Owner must remain." });
}
await _invitationService.DeletePendingInvitationsByEmailAsync(target.Email ?? string.Empty);
var result = await _userManager.DeleteAsync(target);
if (!result.Succeeded)
return StatusCode(500, new { detail = "Failed to delete user." });
return NoContent();
}
}