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:
Yaojia Wang
2025-11-03 22:02:56 +01:00
parent 1cf0ef0d9c
commit 4594ebef84
26 changed files with 1736 additions and 0 deletions

View File

@@ -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);

View File

@@ -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);