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

@@ -0,0 +1,12 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
/// <summary>
/// Command to accept an invitation and create/add user to tenant
/// </summary>
public sealed record AcceptInvitationCommand(
string Token,
string FullName,
string Password
) : IRequest<Guid>; // Returns user ID

View File

@@ -0,0 +1,131 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCommand, Guid>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<AcceptInvitationCommandHandler> _logger;
public AcceptInvitationCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ISecurityTokenService tokenService,
IPasswordHasher passwordHasher,
ILogger<AcceptInvitationCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_userTenantRoleRepository = userTenantRoleRepository;
_tokenService = tokenService;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task<Guid> Handle(AcceptInvitationCommand request, CancellationToken cancellationToken)
{
// Hash the token to find the invitation
var tokenHash = _tokenService.HashToken(request.Token);
// Find invitation by token hash
var invitation = await _invitationRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (invitation == null)
throw new InvalidOperationException("Invalid invitation token");
// Validate invitation is pending
invitation.ValidateForAcceptance();
var email = Email.Create(invitation.Email);
var fullName = FullName.Create(request.FullName);
// Check if user already exists in this tenant
var existingUser = await _userRepository.GetByEmailAsync(invitation.TenantId, email, cancellationToken);
User user;
if (existingUser != null)
{
// User already exists in this tenant
user = existingUser;
_logger.LogInformation(
"User {UserId} already exists in tenant {TenantId}, adding role",
user.Id,
invitation.TenantId);
}
else
{
// Create new user
var passwordHash = _passwordHasher.HashPassword(request.Password);
user = User.CreateLocal(
invitation.TenantId,
email,
passwordHash,
fullName);
await _userRepository.AddAsync(user, cancellationToken);
_logger.LogInformation(
"Created new user {UserId} for invitation acceptance in tenant {TenantId}",
user.Id,
invitation.TenantId);
}
// Check if user already has a role in this tenant
var userId = UserId.Create(user.Id);
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
userId,
invitation.TenantId,
cancellationToken);
if (existingRole != null)
{
// User already has a role - update it
existingRole.UpdateRole(invitation.Role, user.Id);
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
_logger.LogInformation(
"Updated role for user {UserId} in tenant {TenantId} to {Role}",
user.Id,
invitation.TenantId,
invitation.Role);
}
else
{
// Create new UserTenantRole mapping
var userTenantRole = UserTenantRole.Create(
userId,
invitation.TenantId,
invitation.Role,
invitation.InvitedBy);
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
_logger.LogInformation(
"Created role mapping for user {UserId} in tenant {TenantId} with role {Role}",
user.Id,
invitation.TenantId,
invitation.Role);
}
// Mark invitation as accepted
invitation.Accept();
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
_logger.LogInformation(
"Invitation {InvitationId} accepted by user {UserId}",
invitation.Id,
user.Id);
return user.Id;
}
}

View File

@@ -0,0 +1,12 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
/// <summary>
/// Command to cancel a pending invitation
/// </summary>
public sealed record CancelInvitationCommand(
Guid InvitationId,
Guid TenantId,
Guid CancelledBy
) : IRequest<Unit>;

View File

@@ -0,0 +1,48 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
public class CancelInvitationCommandHandler : IRequestHandler<CancelInvitationCommand, Unit>
{
private readonly IInvitationRepository _invitationRepository;
private readonly ILogger<CancelInvitationCommandHandler> _logger;
public CancelInvitationCommandHandler(
IInvitationRepository invitationRepository,
ILogger<CancelInvitationCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_logger = logger;
}
public async Task<Unit> Handle(CancelInvitationCommand request, CancellationToken cancellationToken)
{
var invitationId = InvitationId.Create(request.InvitationId);
var tenantId = TenantId.Create(request.TenantId);
// Get the invitation
var invitation = await _invitationRepository.GetByIdAsync(invitationId, cancellationToken);
if (invitation == null)
throw new InvalidOperationException($"Invitation {request.InvitationId} not found");
// Verify invitation belongs to the tenant (security check)
if (invitation.TenantId != tenantId)
throw new InvalidOperationException("Invitation does not belong to this tenant");
// Cancel the invitation
invitation.Cancel();
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
_logger.LogInformation(
"Invitation {InvitationId} cancelled by user {CancelledBy} in tenant {TenantId}",
request.InvitationId,
request.CancelledBy,
request.TenantId);
return Unit.Value;
}
}

View File

@@ -0,0 +1,14 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.InviteUser;
/// <summary>
/// Command to invite a user to a tenant
/// </summary>
public sealed record InviteUserCommand(
Guid TenantId,
string Email,
string Role,
Guid InvitedBy,
string BaseUrl
) : IRequest<Guid>; // Returns invitation ID

View File

@@ -0,0 +1,135 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.Identity.Domain.Services;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.InviteUser;
public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly ITenantRepository _tenantRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _templateService;
private readonly ILogger<InviteUserCommandHandler> _logger;
public InviteUserCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ITenantRepository tenantRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
ILogger<InviteUserCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_userTenantRoleRepository = userTenantRoleRepository;
_tenantRepository = tenantRepository;
_tokenService = tokenService;
_emailService = emailService;
_templateService = templateService;
_logger = logger;
}
public async Task<Guid> Handle(InviteUserCommand request, CancellationToken cancellationToken)
{
var tenantId = TenantId.Create(request.TenantId);
var invitedBy = UserId.Create(request.InvitedBy);
// Validate role
if (!Enum.TryParse<TenantRole>(request.Role, out var role))
throw new ArgumentException($"Invalid role: {request.Role}");
// Check if tenant exists
var tenant = await _tenantRepository.GetByIdAsync(tenantId, cancellationToken);
if (tenant == null)
throw new InvalidOperationException($"Tenant {request.TenantId} not found");
// Check if inviter exists
var inviter = await _userRepository.GetByIdAsync(invitedBy, cancellationToken);
if (inviter == null)
throw new InvalidOperationException($"Inviter user {request.InvitedBy} not found");
var email = Email.Create(request.Email);
// Check if user already exists in this tenant
var existingUser = await _userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
if (existingUser != null)
{
// Check if user already has a role in this tenant
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
UserId.Create(existingUser.Id),
tenantId,
cancellationToken);
if (existingRole != null)
throw new InvalidOperationException($"User with email {request.Email} is already a member of this tenant");
}
// Check for existing pending invitation
var existingInvitation = await _invitationRepository.GetPendingByEmailAndTenantAsync(
request.Email,
tenantId,
cancellationToken);
if (existingInvitation != null)
throw new InvalidOperationException($"A pending invitation already exists for {request.Email} in this tenant");
// Generate secure token
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
// Create invitation
var invitation = Invitation.Create(
tenantId,
request.Email,
role,
tokenHash,
invitedBy);
await _invitationRepository.AddAsync(invitation, cancellationToken);
// Send invitation email
var invitationLink = $"{request.BaseUrl}/accept-invitation?token={token}";
var htmlBody = _templateService.RenderInvitationEmail(
recipientName: request.Email.Split('@')[0], // Use email prefix as fallback name
tenantName: tenant.Name.Value,
inviterName: inviter.FullName.Value,
invitationUrl: invitationLink);
var emailMessage = new EmailMessage(
To: request.Email,
Subject: $"You've been invited to join {tenant.Name.Value} on ColaFlow",
HtmlBody: htmlBody,
PlainTextBody: $"You've been invited to join {tenant.Name.Value}. Click here to accept: {invitationLink}");
var emailSuccess = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
if (!emailSuccess)
{
_logger.LogWarning(
"Failed to send invitation email to {Email} for tenant {TenantId}",
request.Email,
request.TenantId);
}
else
{
_logger.LogInformation(
"Invitation sent to {Email} for tenant {TenantId} with role {Role}",
request.Email,
request.TenantId,
role);
}
return invitation.Id;
}
}

View File

@@ -0,0 +1,31 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for InvitationAcceptedEvent - logs acceptance
/// </summary>
public class InvitationAcceptedEventHandler : INotificationHandler<InvitationAcceptedEvent>
{
private readonly ILogger<InvitationAcceptedEventHandler> _logger;
public InvitationAcceptedEventHandler(ILogger<InvitationAcceptedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(InvitationAcceptedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Invitation accepted: Email={Email}, Tenant={TenantId}, Role={Role}",
notification.Email,
notification.TenantId,
notification.Role);
// Future: Could send welcome email, track conversion metrics, etc.
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,30 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for InvitationCancelledEvent - logs cancellation
/// </summary>
public class InvitationCancelledEventHandler : INotificationHandler<InvitationCancelledEvent>
{
private readonly ILogger<InvitationCancelledEventHandler> _logger;
public InvitationCancelledEventHandler(ILogger<InvitationCancelledEventHandler> logger)
{
_logger = logger;
}
public Task Handle(InvitationCancelledEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Invitation cancelled: Email={Email}, Tenant={TenantId}",
notification.Email,
notification.TenantId);
// Future: Could notify invited user, track cancellation metrics, etc.
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,32 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Invitations.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for UserInvitedEvent - logs invitation
/// </summary>
public class UserInvitedEventHandler : INotificationHandler<UserInvitedEvent>
{
private readonly ILogger<UserInvitedEventHandler> _logger;
public UserInvitedEventHandler(ILogger<UserInvitedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserInvitedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"User invited: Email={Email}, Tenant={TenantId}, Role={Role}, InvitedBy={InvitedBy}",
notification.Email,
notification.TenantId,
notification.Role,
notification.InvitedBy);
// Future: Could add analytics tracking, audit log entry, etc.
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,22 @@
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
/// <summary>
/// Query to get all pending invitations for a tenant
/// </summary>
public sealed record GetPendingInvitationsQuery(
Guid TenantId
) : IRequest<List<InvitationDto>>;
/// <summary>
/// DTO for invitation data
/// </summary>
public record InvitationDto(
Guid Id,
string Email,
string Role,
string InvitedByName,
Guid InvitedById,
DateTime CreatedAt,
DateTime ExpiresAt);

View File

@@ -0,0 +1,57 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
public class GetPendingInvitationsQueryHandler : IRequestHandler<GetPendingInvitationsQuery, List<InvitationDto>>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly ILogger<GetPendingInvitationsQueryHandler> _logger;
public GetPendingInvitationsQueryHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
ILogger<GetPendingInvitationsQueryHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_logger = logger;
}
public async Task<List<InvitationDto>> Handle(GetPendingInvitationsQuery request, CancellationToken cancellationToken)
{
var tenantId = TenantId.Create(request.TenantId);
// Get all pending invitations for the tenant
var invitations = await _invitationRepository.GetPendingByTenantAsync(tenantId, cancellationToken);
// Get all unique inviter user IDs
var inviterIds = invitations.Select(i => (Guid)i.InvitedBy).Distinct().ToList();
// Fetch all inviters in one query
var inviters = await _userRepository.GetByIdsAsync(inviterIds, cancellationToken);
var inviterDict = inviters.ToDictionary(u => u.Id, u => u.FullName.Value);
// Map to DTOs
var dtos = invitations.Select(i => new InvitationDto(
Id: i.Id,
Email: i.Email,
Role: i.Role.ToString(),
InvitedByName: inviterDict.TryGetValue(i.InvitedBy, out var name) ? name : "Unknown",
InvitedById: i.InvitedBy,
CreatedAt: i.CreatedAt,
ExpiresAt: i.ExpiresAt
)).ToList();
_logger.LogInformation(
"Retrieved {Count} pending invitations for tenant {TenantId}",
dtos.Count,
request.TenantId);
return dtos;
}
}