feat(backend): Implement User Invitation System (Phase 4)
Add complete user invitation system to enable multi-user tenants. Changes: - Created Invitation domain entity with 7-day expiration - Implemented InviteUserCommand with security validation - Implemented AcceptInvitationCommand (creates user + assigns role) - Implemented GetPendingInvitationsQuery - Implemented CancelInvitationCommand - Added TenantInvitationsController with tenant-scoped endpoints - Added public invitation acceptance endpoint to AuthController - Created database migration for invitations table - Registered InvitationRepository in DI container - Created domain event handlers for audit trail Security Features: - Cannot invite as TenantOwner or AIAgent roles - Cross-tenant validation on all endpoints - Secure token generation and hashing - RequireTenantAdmin policy for invite/list - RequireTenantOwner policy for cancel This UNBLOCKS 3 skipped Day 6 tests (RemoveUserFromTenant). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
|
||||
using ColaFlow.Modules.Identity.Application.Commands.Login;
|
||||
using ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
|
||||
using ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
|
||||
using ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
|
||||
using ColaFlow.Modules.Identity.Application.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -208,6 +209,39 @@ public class AuthController(
|
||||
|
||||
return Ok(new { message = "Password reset successfully. Please login with your new password." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accept an invitation and create/join tenant
|
||||
/// </summary>
|
||||
[HttpPost("invitations/accept")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = new AcceptInvitationCommand(
|
||||
request.Token,
|
||||
request.FullName,
|
||||
request.Password);
|
||||
|
||||
var userId = await mediator.Send(command);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
userId,
|
||||
message = "Invitation accepted successfully. You can now log in."
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to accept invitation");
|
||||
return StatusCode(500, new { message = "Failed to accept invitation. Please try again." });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record LoginRequest(
|
||||
@@ -221,3 +255,5 @@ public record VerifyEmailRequest(string Token);
|
||||
public record ForgotPasswordRequest(string Email, string TenantSlug);
|
||||
|
||||
public record ResetPasswordRequest(string Token, string NewPassword);
|
||||
|
||||
public record AcceptInvitationRequest(string Token, string FullName, string Password);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using ColaFlow.Modules.Identity.Application.Commands.InviteUser;
|
||||
using ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
|
||||
using ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ColaFlow.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for managing tenant invitations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/tenants/{tenantId}/invitations")]
|
||||
[Authorize]
|
||||
public class TenantInvitationsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Invite a user to the tenant by email
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = "RequireTenantAdmin")]
|
||||
public async Task<IActionResult> InviteUser(
|
||||
[FromRoute] Guid tenantId,
|
||||
[FromBody] InviteUserRequest request)
|
||||
{
|
||||
// SECURITY: Validate user belongs to target tenant
|
||||
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
|
||||
if (userTenantIdClaim == null)
|
||||
return Unauthorized(new { error = "Tenant information not found in token" });
|
||||
|
||||
var userTenantId = Guid.Parse(userTenantIdClaim);
|
||||
if (userTenantId != tenantId)
|
||||
return StatusCode(403, new { error = "Access denied: You can only invite users to your own tenant" });
|
||||
|
||||
// Extract current user ID from claims
|
||||
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
||||
if (currentUserIdClaim == null)
|
||||
return Unauthorized(new { error = "User ID not found in token" });
|
||||
|
||||
var currentUserId = Guid.Parse(currentUserIdClaim);
|
||||
|
||||
// Build base URL for invitation link
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
|
||||
var command = new InviteUserCommand(
|
||||
tenantId,
|
||||
request.Email,
|
||||
request.Role,
|
||||
currentUserId,
|
||||
baseUrl);
|
||||
|
||||
try
|
||||
{
|
||||
var invitationId = await mediator.Send(command);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
invitationId,
|
||||
message = "Invitation sent successfully",
|
||||
email = request.Email,
|
||||
role = request.Role
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all pending invitations for the tenant
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Authorize(Policy = "RequireTenantAdmin")]
|
||||
public async Task<IActionResult> GetPendingInvitations([FromRoute] Guid tenantId)
|
||||
{
|
||||
// SECURITY: Validate user belongs to target tenant
|
||||
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
|
||||
if (userTenantIdClaim == null)
|
||||
return Unauthorized(new { error = "Tenant information not found in token" });
|
||||
|
||||
var userTenantId = Guid.Parse(userTenantIdClaim);
|
||||
if (userTenantId != tenantId)
|
||||
return StatusCode(403, new { error = "Access denied: You can only view invitations for your own tenant" });
|
||||
|
||||
var query = new GetPendingInvitationsQuery(tenantId);
|
||||
var invitations = await mediator.Send(query);
|
||||
|
||||
return Ok(invitations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel a pending invitation
|
||||
/// </summary>
|
||||
[HttpDelete("{invitationId}")]
|
||||
[Authorize(Policy = "RequireTenantOwner")]
|
||||
public async Task<IActionResult> CancelInvitation(
|
||||
[FromRoute] Guid tenantId,
|
||||
[FromRoute] Guid invitationId)
|
||||
{
|
||||
// SECURITY: Validate user belongs to target tenant
|
||||
var userTenantIdClaim = User.FindFirst("tenant_id")?.Value;
|
||||
if (userTenantIdClaim == null)
|
||||
return Unauthorized(new { error = "Tenant information not found in token" });
|
||||
|
||||
var userTenantId = Guid.Parse(userTenantIdClaim);
|
||||
if (userTenantId != tenantId)
|
||||
return StatusCode(403, new { error = "Access denied: You can only cancel invitations in your own tenant" });
|
||||
|
||||
// Extract current user ID from claims
|
||||
var currentUserIdClaim = User.FindFirst("user_id")?.Value;
|
||||
if (currentUserIdClaim == null)
|
||||
return Unauthorized(new { error = "User ID not found in token" });
|
||||
|
||||
var currentUserId = Guid.Parse(currentUserIdClaim);
|
||||
|
||||
var command = new CancelInvitationCommand(invitationId, tenantId, currentUserId);
|
||||
|
||||
try
|
||||
{
|
||||
await mediator.Send(command);
|
||||
return Ok(new { message = "Invitation cancelled successfully" });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to invite a user to a tenant
|
||||
/// </summary>
|
||||
public record InviteUserRequest(string Email, string Role);
|
||||
Reference in New Issue
Block a user