- Implemented OAuthClientsController for CRUD operations on OAuth clients. - Added SecurityController to manage security settings. - Created SubscriptionsController for handling subscriptions with export functionality. - Developed TenantsController for tenant management including create, edit, and delete operations. - Added views for each controller to facilitate user interaction. - Introduced layout and shared views for consistent admin UI. - Implemented model classes for handling data in views. - Added validation and error handling in forms.
245 lines
7.8 KiB
C#
245 lines
7.8 KiB
C#
using System.Text.Json;
|
|
using MemberCenter.Application.Abstractions;
|
|
using MemberCenter.Application.Models.Account;
|
|
using MemberCenter.Domain.Entities;
|
|
using MemberCenter.Infrastructure.Identity;
|
|
using MemberCenter.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using MemberCenter.Application.Models.Newsletter;
|
|
|
|
namespace MemberCenter.Infrastructure.Services;
|
|
|
|
public sealed class AccountProvisioningService : IAccountProvisioningService
|
|
{
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly MemberCenterDbContext _dbContext;
|
|
private readonly ISendEngineWebhookPublisher _webhookPublisher;
|
|
private readonly ILogger<AccountProvisioningService> _logger;
|
|
|
|
public AccountProvisioningService(
|
|
UserManager<ApplicationUser> userManager,
|
|
MemberCenterDbContext dbContext,
|
|
ISendEngineWebhookPublisher webhookPublisher,
|
|
ILogger<AccountProvisioningService> logger)
|
|
{
|
|
_userManager = userManager;
|
|
_dbContext = dbContext;
|
|
_webhookPublisher = webhookPublisher;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<AccountProvisioningResult> RegisterLocalAsync(string email, string password)
|
|
{
|
|
var normalizedEmail = NormalizeEmail(email);
|
|
var user = new ApplicationUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserName = normalizedEmail,
|
|
Email = normalizedEmail,
|
|
EmailConfirmed = false
|
|
};
|
|
|
|
var result = await _userManager.CreateAsync(user, password);
|
|
if (!result.Succeeded)
|
|
{
|
|
return Failed(result);
|
|
}
|
|
|
|
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(user, "local_registration", new
|
|
{
|
|
email = normalizedEmail
|
|
});
|
|
|
|
return new AccountProvisioningResult(
|
|
true,
|
|
user.Id,
|
|
user.Email,
|
|
user.EmailConfirmed,
|
|
true,
|
|
false,
|
|
linkedSubscriptionsCount,
|
|
Array.Empty<string>());
|
|
}
|
|
|
|
public async Task<AccountProvisioningResult> ProvisionExternalLoginAsync(
|
|
string loginProvider,
|
|
string providerKey,
|
|
string? email,
|
|
bool emailVerified)
|
|
{
|
|
var existingByLogin = await _userManager.FindByLoginAsync(loginProvider, providerKey);
|
|
if (existingByLogin is not null)
|
|
{
|
|
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(existingByLogin, "external_login_reuse", new
|
|
{
|
|
login_provider = loginProvider
|
|
});
|
|
|
|
return new AccountProvisioningResult(
|
|
true,
|
|
existingByLogin.Id,
|
|
existingByLogin.Email,
|
|
existingByLogin.EmailConfirmed,
|
|
false,
|
|
false,
|
|
linkedSubscriptionsCount,
|
|
Array.Empty<string>());
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
{
|
|
return new AccountProvisioningResult(
|
|
false,
|
|
null,
|
|
null,
|
|
false,
|
|
false,
|
|
false,
|
|
0,
|
|
new[] { "External login did not provide an email address." });
|
|
}
|
|
|
|
var normalizedEmail = NormalizeEmail(email);
|
|
var user = await _userManager.FindByEmailAsync(normalizedEmail);
|
|
var createdUser = false;
|
|
|
|
if (user is null)
|
|
{
|
|
user = new ApplicationUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserName = normalizedEmail,
|
|
Email = normalizedEmail,
|
|
EmailConfirmed = emailVerified
|
|
};
|
|
|
|
var createResult = await _userManager.CreateAsync(user);
|
|
if (!createResult.Succeeded)
|
|
{
|
|
return Failed(createResult);
|
|
}
|
|
|
|
createdUser = true;
|
|
}
|
|
else if (emailVerified && !user.EmailConfirmed)
|
|
{
|
|
user.EmailConfirmed = true;
|
|
await _userManager.UpdateAsync(user);
|
|
}
|
|
|
|
var addLoginResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(loginProvider, providerKey, loginProvider));
|
|
if (!addLoginResult.Succeeded
|
|
&& addLoginResult.Errors.All(x => x.Code != nameof(IdentityErrorDescriber.LoginAlreadyAssociated)))
|
|
{
|
|
return Failed(addLoginResult);
|
|
}
|
|
|
|
var linkedSubscriptions = await LinkSubscriptionsAndAuditAsync(user, "external_login_linked", new
|
|
{
|
|
login_provider = loginProvider,
|
|
created_user = createdUser
|
|
});
|
|
|
|
return new AccountProvisioningResult(
|
|
true,
|
|
user.Id,
|
|
user.Email,
|
|
user.EmailConfirmed,
|
|
createdUser,
|
|
true,
|
|
linkedSubscriptions,
|
|
Array.Empty<string>());
|
|
}
|
|
|
|
private async Task<int> LinkSubscriptionsAndAuditAsync(ApplicationUser user, string source, object payload)
|
|
{
|
|
var email = NormalizeEmail(user.Email ?? user.UserName ?? string.Empty);
|
|
var subscriptions = await _dbContext.NewsletterSubscriptions
|
|
.Where(x => x.UserId == null && x.Email.ToLower() == email)
|
|
.ToListAsync();
|
|
|
|
if (subscriptions.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
foreach (var subscription in subscriptions)
|
|
{
|
|
subscription.UserId = user.Id;
|
|
}
|
|
|
|
var linkedSubscriptions = subscriptions
|
|
.Select(subscription => new SubscriptionDto(
|
|
subscription.Id,
|
|
subscription.ListId,
|
|
subscription.Email,
|
|
subscription.Status,
|
|
subscription.Preferences.RootElement.Clone(),
|
|
subscription.CreatedAt))
|
|
.ToList();
|
|
|
|
_dbContext.AuditLogs.Add(new AuditLog
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ActorType = "system",
|
|
ActorId = user.Id,
|
|
Action = "subscription.linked_to_user",
|
|
Payload = JsonDocument.Parse(JsonSerializer.Serialize(new
|
|
{
|
|
user_id = user.Id,
|
|
email,
|
|
linked_subscriptions = subscriptions.Count,
|
|
source,
|
|
metadata = payload
|
|
}))
|
|
});
|
|
|
|
await _dbContext.SaveChangesAsync();
|
|
|
|
await PublishLinkedSubscriptionEventsAsync(linkedSubscriptions);
|
|
return subscriptions.Count;
|
|
}
|
|
|
|
private async Task PublishLinkedSubscriptionEventsAsync(IReadOnlyList<SubscriptionDto> subscriptions)
|
|
{
|
|
if (subscriptions.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var listIds = subscriptions.Select(x => x.ListId).Distinct().ToList();
|
|
var tenantMap = await _dbContext.NewsletterLists
|
|
.Where(x => listIds.Contains(x.Id))
|
|
.ToDictionaryAsync(x => x.Id, x => x.TenantId);
|
|
|
|
foreach (var subscription in subscriptions)
|
|
{
|
|
if (!tenantMap.TryGetValue(subscription.ListId, out var tenantId) || tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning(
|
|
"Skip linked subscription event because list {ListId} has no tenant mapping",
|
|
subscription.ListId);
|
|
continue;
|
|
}
|
|
|
|
await _webhookPublisher.PublishSubscriptionEventAsync("subscription.linked_to_user", tenantId, subscription);
|
|
}
|
|
}
|
|
|
|
private static AccountProvisioningResult Failed(IdentityResult result) =>
|
|
new(
|
|
false,
|
|
null,
|
|
null,
|
|
false,
|
|
false,
|
|
false,
|
|
0,
|
|
result.Errors.Select(x => x.Description).ToArray());
|
|
|
|
private static string NormalizeEmail(string email) =>
|
|
email.Trim().ToLowerInvariant();
|
|
}
|