In progress
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled

This commit is contained in:
Yaojia Wang
2025-11-03 14:00:24 +01:00
parent fe8ad1c1f9
commit 1f66b25f30
74 changed files with 9609 additions and 28 deletions

View File

@@ -0,0 +1,10 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public record LoginCommand(
string TenantSlug,
string Email,
string Password
) : IRequest<LoginResponseDto>;

View File

@@ -0,0 +1,84 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDto>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IPasswordHasher and IJwtService
public LoginCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
}
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
{
// 1. Find tenant
var slug = TenantSlug.Create(request.TenantSlug);
var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken);
if (tenant == null)
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 2. Find user
var email = Email.Create(request.Email);
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
if (user == null)
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 3. Verify password (simplified - TODO: use IPasswordHasher)
// if (!PasswordHasher.Verify(request.Password, user.PasswordHash))
// {
// throw new UnauthorizedAccessException("Invalid credentials");
// }
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 5. Update last login time
user.RecordLogin();
await _userRepository.UpdateAsync(user, cancellationToken);
// 6. Return result
return new LoginResponseDto
{
User = new UserDto
{
Id = user.Id,
TenantId = tenant.Id,
Email = user.Email.Value,
FullName = user.FullName.Value,
Status = user.Status.ToString(),
AuthProvider = user.AuthProvider.ToString(),
IsEmailVerified = user.EmailVerifiedAt.HasValue,
LastLoginAt = user.LastLoginAt,
CreatedAt = user.CreatedAt
},
Tenant = new TenantDto
{
Id = tenant.Id,
Name = tenant.Name.Value,
Slug = tenant.Slug.Value,
Status = tenant.Status.ToString(),
Plan = tenant.Plan.ToString(),
SsoEnabled = tenant.SsoConfig != null,
SsoProvider = tenant.SsoConfig?.Provider.ToString(),
CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
},
AccessToken = accessToken
};
}
}

View File

@@ -0,0 +1,19 @@
using FluentValidation;
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
public class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.TenantSlug)
.NotEmpty().WithMessage("Tenant slug is required");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required");
}
}

View File

@@ -0,0 +1,19 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
public record RegisterTenantCommand(
string TenantName,
string TenantSlug,
string SubscriptionPlan,
string AdminEmail,
string AdminPassword,
string AdminFullName
) : IRequest<RegisterTenantResult>;
public record RegisterTenantResult(
TenantDto Tenant,
UserDto AdminUser,
string AccessToken
);

View File

@@ -0,0 +1,83 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using MediatR;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantCommand, RegisterTenantResult>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IJwtService and IPasswordHasher
public RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
}
public async Task<RegisterTenantResult> Handle(
RegisterTenantCommand request,
CancellationToken cancellationToken)
{
// 1. Validate slug uniqueness
var slug = TenantSlug.Create(request.TenantSlug);
var slugExists = await _tenantRepository.ExistsBySlugAsync(slug, cancellationToken);
if (slugExists)
{
throw new InvalidOperationException($"Tenant slug '{request.TenantSlug}' is already taken");
}
// 2. Create tenant
var plan = Enum.Parse<SubscriptionPlan>(request.SubscriptionPlan);
var tenant = Tenant.Create(
TenantName.Create(request.TenantName),
slug,
plan);
await _tenantRepository.AddAsync(tenant, cancellationToken);
// 3. Create admin user
// Note: In production, hash password first using IPasswordHasher
var adminUser = User.CreateLocal(
TenantId.Create(tenant.Id),
Email.Create(request.AdminEmail),
request.AdminPassword, // TODO: Hash password
FullName.Create(request.AdminFullName));
await _userRepository.AddAsync(adminUser, cancellationToken);
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 5. Return result
return new RegisterTenantResult(
new Dtos.TenantDto
{
Id = tenant.Id,
Name = tenant.Name.Value,
Slug = tenant.Slug.Value,
Status = tenant.Status.ToString(),
Plan = tenant.Plan.ToString(),
SsoEnabled = tenant.SsoConfig != null,
SsoProvider = tenant.SsoConfig?.Provider.ToString(),
CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
},
new Dtos.UserDto
{
Id = adminUser.Id,
TenantId = tenant.Id,
Email = adminUser.Email.Value,
FullName = adminUser.FullName.Value,
Status = adminUser.Status.ToString(),
AuthProvider = adminUser.AuthProvider.ToString(),
IsEmailVerified = adminUser.EmailVerifiedAt.HasValue,
CreatedAt = adminUser.CreatedAt
},
accessToken);
}
}

View File

@@ -0,0 +1,43 @@
using FluentValidation;
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterTenant;
public class RegisterTenantCommandValidator : AbstractValidator<RegisterTenantCommand>
{
public RegisterTenantCommandValidator()
{
RuleFor(x => x.TenantName)
.NotEmpty().WithMessage("Tenant name is required")
.MinimumLength(2).WithMessage("Tenant name must be at least 2 characters")
.MaximumLength(100).WithMessage("Tenant name cannot exceed 100 characters");
RuleFor(x => x.TenantSlug)
.NotEmpty().WithMessage("Tenant slug is required")
.MinimumLength(3).WithMessage("Tenant slug must be at least 3 characters")
.MaximumLength(50).WithMessage("Tenant slug cannot exceed 50 characters")
.Matches("^[a-z0-9]+(?:-[a-z0-9]+)*$")
.WithMessage("Tenant slug can only contain lowercase letters, numbers, and hyphens");
RuleFor(x => x.SubscriptionPlan)
.NotEmpty().WithMessage("Subscription plan is required")
.Must(plan => new[] { "Free", "Starter", "Professional", "Enterprise" }.Contains(plan))
.WithMessage("Invalid subscription plan");
RuleFor(x => x.AdminEmail)
.NotEmpty().WithMessage("Admin email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.AdminPassword)
.NotEmpty().WithMessage("Admin password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters")
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter")
.Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter")
.Matches("[0-9]").WithMessage("Password must contain at least one digit")
.Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character");
RuleFor(x => x.AdminFullName)
.NotEmpty().WithMessage("Admin full name is required")
.MinimumLength(2).WithMessage("Full name must be at least 2 characters")
.MaximumLength(100).WithMessage("Full name cannot exceed 100 characters");
}
}