Adds user management
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user