diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPasswordService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPasswordService.cs
new file mode 100644
index 0000000000..4c0f3b37c4
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPasswordService.cs
@@ -0,0 +1,22 @@
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service for user password operations.
+///
+public interface IUserPasswordService
+{
+ ///
+ /// Initiates the forgot password flow by sending a reset email.
+ ///
+ Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken);
+
+ ///
+ /// Resets a user's password using a token.
+ ///
+ Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken);
+
+ ///
+ /// Changes the current user's password.
+ ///
+ Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId);
+}
diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPermissionService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPermissionService.cs
new file mode 100644
index 0000000000..4e1ecd314d
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPermissionService.cs
@@ -0,0 +1,22 @@
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service for user permission operations.
+///
+public interface IUserPermissionService
+{
+ ///
+ /// Gets all permissions for a user.
+ ///
+ Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken);
+
+ ///
+ /// Checks if a user has a specific permission.
+ ///
+ Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default);
+
+ ///
+ /// Invalidates the permission cache for a user.
+ ///
+ Task InvalidatePermissionCacheAsync(string userId, CancellationToken cancellationToken);
+}
diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs
new file mode 100644
index 0000000000..747f50d54e
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs
@@ -0,0 +1,45 @@
+using FSH.Framework.Shared.Storage;
+using FSH.Modules.Identity.Contracts.DTOs;
+
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service for user profile operations.
+///
+public interface IUserProfileService
+{
+ ///
+ /// Gets a user by ID.
+ ///
+ Task GetAsync(string userId, CancellationToken cancellationToken);
+
+ ///
+ /// Gets all users.
+ ///
+ Task> GetListAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Gets the total user count.
+ ///
+ Task GetCountAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Updates a user's profile.
+ ///
+ Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage);
+
+ ///
+ /// Checks if a user exists with the given email.
+ ///
+ Task ExistsWithEmailAsync(string email, string? exceptId = null);
+
+ ///
+ /// Checks if a user exists with the given username.
+ ///
+ Task ExistsWithNameAsync(string name);
+
+ ///
+ /// Checks if a user exists with the given phone number.
+ ///
+ Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null);
+}
diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRegistrationService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRegistrationService.cs
new file mode 100644
index 0000000000..ca5c4a6cda
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRegistrationService.cs
@@ -0,0 +1,38 @@
+using System.Security.Claims;
+
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service for user registration and external authentication.
+///
+public interface IUserRegistrationService
+{
+ ///
+ /// Registers a new user with password.
+ ///
+ Task RegisterAsync(
+ string firstName,
+ string lastName,
+ string email,
+ string userName,
+ string password,
+ string confirmPassword,
+ string phoneNumber,
+ string origin,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Gets or creates a user from an external authentication principal.
+ ///
+ Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal);
+
+ ///
+ /// Confirms a user's email address.
+ ///
+ Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken);
+
+ ///
+ /// Confirms a user's phone number.
+ ///
+ Task ConfirmPhoneNumberAsync(string userId, string code);
+}
diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRoleService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRoleService.cs
new file mode 100644
index 0000000000..5ea90cc768
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRoleService.cs
@@ -0,0 +1,19 @@
+using FSH.Modules.Identity.Contracts.DTOs;
+
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service for user role management.
+///
+public interface IUserRoleService
+{
+ ///
+ /// Assigns roles to a user.
+ ///
+ Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken);
+
+ ///
+ /// Gets all roles for a user.
+ ///
+ Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken);
+}
diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserStatusService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserStatusService.cs
new file mode 100644
index 0000000000..9c8213bb60
--- /dev/null
+++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserStatusService.cs
@@ -0,0 +1,17 @@
+namespace FSH.Modules.Identity.Contracts.Services;
+
+///
+/// Service for user status and lifecycle operations.
+///
+public interface IUserStatusService
+{
+ ///
+ /// Toggles a user's active status.
+ ///
+ Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken);
+
+ ///
+ /// Soft-deletes a user by deactivating them.
+ ///
+ Task DeleteAsync(string userId);
+}
diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs
index 2afb023cb4..da3ea97e04 100644
--- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs
+++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs
@@ -77,7 +77,18 @@ public void ConfigureServices(IHostApplicationBuilder builder)
services.AddScoped();
services.AddScoped();
services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService());
+
+ // User services - focused single-responsibility services
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+ services.AddTransient();
+
+ // Facade for backward compatibility
services.AddTransient();
+
services.AddTransient();
services.AddHeroStorage(builder.Configuration);
services.AddScoped();
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs
similarity index 73%
rename from src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs
rename to src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs
index cbf0177ab1..bf897bfc6d 100644
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs
@@ -1,12 +1,27 @@
-using FSH.Framework.Core.Exceptions;
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Jobs.Services;
using FSH.Framework.Mailing;
+using FSH.Framework.Mailing.Services;
+using FSH.Framework.Shared.Multitenancy;
+using FSH.Modules.Identity.Contracts.Services;
+using FSH.Modules.Identity.Data;
+using FSH.Modules.Identity.Domain;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.WebUtilities;
using System.Collections.ObjectModel;
using System.Text;
namespace FSH.Modules.Identity.Services;
-internal sealed partial class UserService
+internal sealed class UserPasswordService(
+ UserManager userManager,
+ IdentityDbContext db,
+ IJobService jobService,
+ IMailService mailService,
+ IMultiTenantContextAccessor multiTenantContextAccessor,
+ IPasswordHistoryService passwordHistoryService,
+ IPasswordExpiryService passwordExpiryService) : IUserPasswordService
{
public async Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken)
{
@@ -80,9 +95,17 @@ public async Task ChangePasswordAsync(string password, string newPassword, strin
await db.SaveChangesAsync();
// Update password expiry date
- await _passwordExpiryService.UpdateLastPasswordChangeDateAsync(userId);
+ await passwordExpiryService.UpdateLastPasswordChangeDateAsync(userId);
// Save to history
- await _passwordHistoryService.SavePasswordHistoryAsync(userId);
+ await passwordHistoryService.SavePasswordHistoryAsync(userId);
}
-}
\ No newline at end of file
+
+ private void EnsureValidTenant()
+ {
+ if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
+ {
+ throw new UnauthorizedException("invalid tenant");
+ }
+ }
+}
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs b/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs
similarity index 83%
rename from src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs
rename to src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs
index b936de9cf6..03224ce6f3 100644
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs
@@ -1,11 +1,19 @@
-using FSH.Framework.Caching;
+using FSH.Framework.Caching;
using FSH.Framework.Core.Exceptions;
using FSH.Framework.Shared.Constants;
+using FSH.Modules.Identity.Contracts.Services;
+using FSH.Modules.Identity.Data;
+using FSH.Modules.Identity.Domain;
+using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace FSH.Modules.Identity.Services;
-internal sealed partial class UserService
+internal sealed class UserPermissionService(
+ UserManager userManager,
+ RoleManager roleManager,
+ IdentityDbContext db,
+ ICacheService cache) : IUserPermissionService
{
public async Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken)
{
@@ -51,4 +59,4 @@ public Task InvalidatePermissionCacheAsync(string userId, CancellationToken canc
{
return cache.RemoveItemAsync(GetPermissionCacheKey(userId), cancellationToken);
}
-}
\ No newline at end of file
+}
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Profile.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs
similarity index 64%
rename from src/Modules/Identity/Modules.Identity/Services/UserService.Profile.cs
rename to src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs
index 9d39b12640..9bec283971 100644
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.Profile.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs
@@ -1,14 +1,30 @@
+using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Framework.Shared.Storage;
using FSH.Framework.Storage;
+using FSH.Framework.Storage.Services;
+using FSH.Framework.Web.Origin;
using FSH.Modules.Identity.Contracts.DTOs;
+using FSH.Modules.Identity.Contracts.Services;
using FSH.Modules.Identity.Domain;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
namespace FSH.Modules.Identity.Services;
-internal sealed partial class UserService
+internal sealed class UserProfileService(
+ UserManager userManager,
+ SignInManager signInManager,
+ IStorageService storageService,
+ IMultiTenantContextAccessor multiTenantContextAccessor,
+ IOptions originOptions,
+ IHttpContextAccessor httpContextAccessor) : IUserProfileService
{
+ private readonly Uri? _originUrl = originOptions.Value.OriginUrl;
+
public async Task GetAsync(string userId, CancellationToken cancellationToken)
{
var user = await userManager.Users
@@ -105,4 +121,43 @@ public async Task ExistsWithPhoneNumberAsync(string phoneNumber, string? e
EnsureValidTenant();
return await userManager.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber) is FshUser user && user.Id != exceptId;
}
+
+ private void EnsureValidTenant()
+ {
+ if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
+ {
+ throw new UnauthorizedException("invalid tenant");
+ }
+ }
+
+ private string? ResolveImageUrl(Uri? imageUrl)
+ {
+ if (imageUrl is null)
+ {
+ return null;
+ }
+
+ // Absolute URLs (e.g., S3) pass through unchanged.
+ if (imageUrl.IsAbsoluteUri)
+ {
+ return imageUrl.ToString();
+ }
+
+ // For relative paths from local storage, prefix with the API origin and wwwroot.
+ if (_originUrl is null)
+ {
+ var request = httpContextAccessor.HttpContext?.Request;
+ if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue)
+ {
+ var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}";
+ var relativePath = imageUrl.ToString().TrimStart('/');
+ return $"{baseUri.TrimEnd('/')}/{relativePath}";
+ }
+
+ return imageUrl.ToString();
+ }
+
+ var originRelativePath = imageUrl.ToString().TrimStart('/');
+ return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}";
+ }
}
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs
similarity index 82%
rename from src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs
rename to src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs
index f60edf4c01..1feac300fd 100644
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs
@@ -1,19 +1,33 @@
+using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Core.Common;
using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Eventing.Outbox;
+using FSH.Framework.Jobs.Services;
using FSH.Framework.Mailing;
+using FSH.Framework.Mailing.Services;
using FSH.Framework.Shared.Constants;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Identity.Contracts.Events;
+using FSH.Modules.Identity.Contracts.Services;
+using FSH.Modules.Identity.Data;
using FSH.Modules.Identity.Domain;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using System.Collections.ObjectModel;
+using System.Globalization;
using System.Security.Claims;
using System.Text;
namespace FSH.Modules.Identity.Services;
-internal sealed partial class UserService
+internal sealed class UserRegistrationService(
+ UserManager userManager,
+ IdentityDbContext db,
+ IJobService jobService,
+ IMailService mailService,
+ IMultiTenantContextAccessor multiTenantContextAccessor,
+ IOutboxStore outboxStore) : IUserRegistrationService
{
public async Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal)
{
@@ -35,6 +49,71 @@ public async Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principa
return user.Id;
}
+ public async Task RegisterAsync(
+ string firstName,
+ string lastName,
+ string email,
+ string userName,
+ string password,
+ string confirmPassword,
+ string phoneNumber,
+ string origin,
+ CancellationToken cancellationToken)
+ {
+ ValidatePasswordMatch(password, confirmPassword);
+
+ var user = await CreateUserWithPasswordAsync(firstName, lastName, email, userName, password, phoneNumber);
+ await AssignDefaultRoleAndGroupsAsync(user, "System", cancellationToken);
+ await SendConfirmationEmailAsync(user, origin, cancellationToken);
+ await PublishUserRegisteredAsync(user, "Identity", cancellationToken);
+
+ return user.Id;
+ }
+
+ public async Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken)
+ {
+ EnsureValidTenant();
+
+ var user = await userManager.Users
+ .Where(u => u.Id == userId && !u.EmailConfirmed)
+ .FirstOrDefaultAsync(cancellationToken);
+
+ _ = user ?? throw new CustomException("An error occurred while confirming E-Mail.");
+
+ code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
+ var result = await userManager.ConfirmEmailAsync(user, code);
+
+ return result.Succeeded
+ ? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email)
+ : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email));
+ }
+
+ public async Task ConfirmPhoneNumberAsync(string userId, string code)
+ {
+ EnsureValidTenant();
+
+ var user = await userManager.Users
+ .Where(u => u.Id == userId && !u.PhoneNumberConfirmed)
+ .FirstOrDefaultAsync();
+
+ _ = user ?? throw new CustomException("An error occurred while confirming phone number.");
+
+ code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
+ var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code);
+
+ return result.Succeeded
+ ? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber)
+ : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber));
+ }
+
+ private void EnsureValidTenant()
+ {
+ if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
+ {
+ throw new UnauthorizedException("invalid tenant");
+ }
+ }
+
private static string ExtractEmailFromPrincipal(ClaimsPrincipal principal)
{
return principal.FindFirstValue(ClaimTypes.Email)
@@ -96,27 +175,6 @@ private async Task EnsureUniqueUserNameAsync(string userName)
return userName;
}
- public async Task RegisterAsync(
- string firstName,
- string lastName,
- string email,
- string userName,
- string password,
- string confirmPassword,
- string phoneNumber,
- string origin,
- CancellationToken cancellationToken)
- {
- ValidatePasswordMatch(password, confirmPassword);
-
- var user = await CreateUserWithPasswordAsync(firstName, lastName, email, userName, password, phoneNumber);
- await AssignDefaultRoleAndGroupsAsync(user, "System", cancellationToken);
- await SendConfirmationEmailAsync(user, origin, cancellationToken);
- await PublishUserRegisteredAsync(user, "Identity", cancellationToken);
-
- return user.Id;
- }
-
private static void ValidatePasswordMatch(string password, string confirmPassword)
{
if (password != confirmPassword)
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs
similarity index 83%
rename from src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs
rename to src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs
index 2d80ddaf5a..935faa18f8 100644
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs
@@ -1,12 +1,21 @@
+using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Core.Exceptions;
using FSH.Framework.Shared.Constants;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Identity.Contracts.DTOs;
+using FSH.Modules.Identity.Contracts.Services;
+using FSH.Modules.Identity.Data;
+using FSH.Modules.Identity.Domain;
+using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace FSH.Modules.Identity.Services;
-internal sealed partial class UserService
+internal sealed class UserRoleService(
+ UserManager userManager,
+ RoleManager roleManager,
+ IdentityDbContext db,
+ IMultiTenantContextAccessor multiTenantContextAccessor) : IUserRoleService
{
public async Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken)
{
@@ -24,7 +33,30 @@ public async Task AssignRolesAsync(string userId, List user
return "User Roles Updated Successfully.";
}
- private async Task ValidateAdminRoleChangeAsync(Domain.FshUser user, List userRoles)
+ public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken)
+ {
+ var user = await userManager.FindByIdAsync(userId)
+ ?? throw new NotFoundException("user not found");
+
+ var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken)
+ ?? throw new NotFoundException("roles not found");
+
+ var userRoles = new List();
+ foreach (var role in roles)
+ {
+ userRoles.Add(new UserRoleDto
+ {
+ RoleId = role.Id,
+ RoleName = role.Name,
+ Description = role.Description,
+ Enabled = await userManager.IsInRoleAsync(user, role.Name!)
+ });
+ }
+
+ return userRoles;
+ }
+
+ private async Task ValidateAdminRoleChangeAsync(FshUser user, List userRoles)
{
bool isRemovingAdminRole = userRoles.Exists(a => !a.Enabled && a.RoleName == RoleConstants.Admin);
if (!isRemovingAdminRole)
@@ -46,7 +78,7 @@ private async Task ValidateAdminRoleChangeAsync(Domain.FshUser user, List> ProcessRoleAssignmentsAsync(Domain.FshUser user, List userRoles)
+ private async Task> ProcessRoleAssignmentsAsync(FshUser user, List userRoles)
{
var assignedRoles = new List();
@@ -89,7 +121,7 @@ private async Task> ProcessRoleAssignmentsAsync(Domain.FshUser user
return assignedRoles;
}
- private async Task RaiseRolesAssignedEventAsync(Domain.FshUser user, List assignedRoles, CancellationToken cancellationToken)
+ private async Task RaiseRolesAssignedEventAsync(FshUser user, List assignedRoles, CancellationToken cancellationToken)
{
if (assignedRoles.Count == 0)
{
@@ -100,27 +132,4 @@ private async Task RaiseRolesAssignedEventAsync(Domain.FshUser user, List> GetUserRolesAsync(string userId, CancellationToken cancellationToken)
- {
- var user = await userManager.FindByIdAsync(userId)
- ?? throw new NotFoundException("user not found");
-
- var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken)
- ?? throw new NotFoundException("roles not found");
-
- var userRoles = new List();
- foreach (var role in roles)
- {
- userRoles.Add(new UserRoleDto
- {
- RoleId = role.Id,
- RoleName = role.Name,
- Description = role.Description,
- Enabled = await userManager.IsInRoleAsync(user, role.Name!)
- });
- }
-
- return userRoles;
- }
}
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Confirmation.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.Confirmation.cs
deleted file mode 100644
index f0dd199431..0000000000
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.Confirmation.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using FSH.Framework.Core.Exceptions;
-using Microsoft.AspNetCore.WebUtilities;
-using Microsoft.EntityFrameworkCore;
-using System.Globalization;
-using System.Text;
-
-namespace FSH.Modules.Identity.Services;
-
-internal sealed partial class UserService
-{
- public async Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken)
- {
- EnsureValidTenant();
-
- var user = await userManager.Users
- .Where(u => u.Id == userId && !u.EmailConfirmed)
- .FirstOrDefaultAsync(cancellationToken);
-
- _ = user ?? throw new CustomException("An error occurred while confirming E-Mail.");
-
- code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
- var result = await userManager.ConfirmEmailAsync(user, code);
-
- return result.Succeeded
- ? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email)
- : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email));
- }
-
- public async Task ConfirmPhoneNumberAsync(string userId, string code)
- {
- EnsureValidTenant();
-
- var user = await userManager.Users
- .Where(u => u.Id == userId && !u.PhoneNumberConfirmed)
- .FirstOrDefaultAsync();
-
- _ = user ?? throw new CustomException("An error occurred while confirming phone number.");
-
- code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
- var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code);
-
- return result.Succeeded
- ? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber)
- : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber));
- }
-}
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.cs
index f816629ae7..b3d9b997d2 100644
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/UserService.cs
@@ -1,84 +1,94 @@
-using Finbuckle.MultiTenant.Abstractions;
-using FSH.Framework.Caching;
-using FSH.Framework.Core.Context;
-using FSH.Framework.Eventing.Outbox;
-using FSH.Framework.Jobs.Services;
-using FSH.Framework.Mailing.Services;
-using FSH.Framework.Shared.Multitenancy;
-using FSH.Framework.Storage.Services;
-using FSH.Framework.Web.Origin;
-using FSH.Modules.Auditing.Contracts;
+using FSH.Framework.Shared.Storage;
+using FSH.Modules.Identity.Contracts.DTOs;
using FSH.Modules.Identity.Contracts.Services;
-using FSH.Modules.Identity.Data;
-using FSH.Modules.Identity.Domain;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Identity;
-using Microsoft.Extensions.Options;
+using System.Security.Claims;
namespace FSH.Modules.Identity.Services;
-internal sealed partial class UserService(
- UserManager userManager,
- SignInManager signInManager,
- RoleManager roleManager,
- IdentityDbContext db,
- ICacheService cache,
- IJobService jobService,
- IMailService mailService,
- IMultiTenantContextAccessor multiTenantContextAccessor,
- IStorageService storageService,
- IOutboxStore outboxStore,
- IOptions originOptions,
- IHttpContextAccessor httpContextAccessor,
- ICurrentUser currentUser,
- IAuditClient auditClient,
- IPasswordHistoryService passwordHistoryService,
- IPasswordExpiryService passwordExpiryService
- ) : IUserService
+///
+/// Facade service that delegates to focused single-responsibility services.
+/// Maintained for backward compatibility with existing consumers.
+///
+internal sealed class UserService(
+ IUserRegistrationService registrationService,
+ IUserProfileService profileService,
+ IUserStatusService statusService,
+ IUserRoleService roleService,
+ IUserPasswordService passwordService,
+ IUserPermissionService permissionService) : IUserService
{
- private readonly Uri? _originUrl = originOptions.Value.OriginUrl;
- private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor;
- private readonly ICurrentUser _currentUser = currentUser;
- private readonly IAuditClient _auditClient = auditClient;
- private readonly IPasswordHistoryService _passwordHistoryService = passwordHistoryService;
- private readonly IPasswordExpiryService _passwordExpiryService = passwordExpiryService;
-
- private void EnsureValidTenant()
- {
- if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
- {
- throw new FSH.Framework.Core.Exceptions.UnauthorizedException("invalid tenant");
- }
- }
-
- private string? ResolveImageUrl(Uri? imageUrl)
- {
- if (imageUrl is null)
- {
- return null;
- }
-
- // Absolute URLs (e.g., S3) pass through unchanged.
- if (imageUrl.IsAbsoluteUri)
- {
- return imageUrl.ToString();
- }
-
- // For relative paths from local storage, prefix with the API origin and wwwroot.
- if (_originUrl is null)
- {
- var request = _httpContextAccessor.HttpContext?.Request;
- if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue)
- {
- var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}";
- var relativePath = imageUrl.ToString().TrimStart('/');
- return $"{baseUri.TrimEnd('/')}/{relativePath}";
- }
-
- return imageUrl.ToString();
- }
-
- var originRelativePath = imageUrl.ToString().TrimStart('/');
- return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}";
- }
+ // Registration operations (delegated to IUserRegistrationService)
+ public Task RegisterAsync(
+ string firstName,
+ string lastName,
+ string email,
+ string userName,
+ string password,
+ string confirmPassword,
+ string phoneNumber,
+ string origin,
+ CancellationToken cancellationToken)
+ => registrationService.RegisterAsync(firstName, lastName, email, userName, password, confirmPassword, phoneNumber, origin, cancellationToken);
+
+ public Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal)
+ => registrationService.GetOrCreateFromPrincipalAsync(principal);
+
+ public Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken)
+ => registrationService.ConfirmEmailAsync(userId, code, tenant, cancellationToken);
+
+ public Task ConfirmPhoneNumberAsync(string userId, string code)
+ => registrationService.ConfirmPhoneNumberAsync(userId, code);
+
+ // Profile operations (delegated to IUserProfileService)
+ public Task GetAsync(string userId, CancellationToken cancellationToken)
+ => profileService.GetAsync(userId, cancellationToken);
+
+ public Task> GetListAsync(CancellationToken cancellationToken)
+ => profileService.GetListAsync(cancellationToken);
+
+ public Task GetCountAsync(CancellationToken cancellationToken)
+ => profileService.GetCountAsync(cancellationToken);
+
+ public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage)
+ => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage);
+
+ public Task ExistsWithEmailAsync(string email, string? exceptId = null)
+ => profileService.ExistsWithEmailAsync(email, exceptId);
+
+ public Task ExistsWithNameAsync(string name)
+ => profileService.ExistsWithNameAsync(name);
+
+ public Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null)
+ => profileService.ExistsWithPhoneNumberAsync(phoneNumber, exceptId);
+
+ // Status operations (delegated to IUserStatusService)
+ public Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken)
+ => statusService.ToggleStatusAsync(activateUser, userId, cancellationToken);
+
+ public Task DeleteAsync(string userId)
+ => statusService.DeleteAsync(userId);
+
+ // Role operations (delegated to IUserRoleService)
+ public Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken)
+ => roleService.AssignRolesAsync(userId, userRoles, cancellationToken);
+
+ public Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken)
+ => roleService.GetUserRolesAsync(userId, cancellationToken);
+
+ // Password operations (delegated to IUserPasswordService)
+ public Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken)
+ => passwordService.ForgotPasswordAsync(email, origin, cancellationToken);
+
+ public Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken)
+ => passwordService.ResetPasswordAsync(email, password, token, cancellationToken);
+
+ public Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId)
+ => passwordService.ChangePasswordAsync(password, newPassword, confirmNewPassword, userId);
+
+ // Permission operations (delegated to IUserPermissionService)
+ public Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken)
+ => permissionService.GetPermissionsAsync(userId, cancellationToken);
+
+ public Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default)
+ => permissionService.HasPermissionAsync(userId, permission, cancellationToken);
}
diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs
similarity index 88%
rename from src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs
rename to src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs
index 5cf648492f..4a1ecf9276 100644
--- a/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs
+++ b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs
@@ -1,12 +1,21 @@
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Core.Context;
using FSH.Framework.Core.Exceptions;
using FSH.Framework.Shared.Constants;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Auditing.Contracts;
+using FSH.Modules.Identity.Contracts.Services;
using FSH.Modules.Identity.Domain;
+using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace FSH.Modules.Identity.Services;
-internal sealed partial class UserService
+internal sealed class UserStatusService(
+ UserManager userManager,
+ IMultiTenantContextAccessor multiTenantContextAccessor,
+ ICurrentUser currentUser,
+ IAuditClient auditClient) : IUserStatusService
{
public async Task DeleteAsync(string userId)
{
@@ -37,12 +46,20 @@ public async Task ToggleStatusAsync(bool activateUser, string userId, Cancellati
await SaveAndAuditAsync(context, cancellationToken);
}
+ private void EnsureValidTenant()
+ {
+ if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
+ {
+ throw new UnauthorizedException("invalid tenant");
+ }
+ }
+
private async Task BuildToggleContextAsync(
string userId,
bool activateUser,
CancellationToken cancellationToken)
{
- var actorId = _currentUser.GetUserId();
+ var actorId = currentUser.GetUserId();
if (actorId == Guid.Empty)
{
throw new UnauthorizedException("authenticated user required to toggle status");
@@ -126,7 +143,7 @@ private async Task SaveAndAuditAsync(
throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList());
}
- await _auditClient.WriteActivityAsync(
+ await auditClient.WriteActivityAsync(
ActivityKind.Command,
name: "ToggleUserStatus",
statusCode: 204,
@@ -154,7 +171,7 @@ private async Task AuditPolicyFailureAsync(
["action"] = context.ActivateUser ? "activate" : "deactivate"
};
- await _auditClient.WriteSecurityAsync(
+ await auditClient.WriteSecurityAsync(
SecurityAction.PolicyFailed,
subjectId: context.ActorId.ToString(),
reasonCode: reason,