Skip to content

Commit 1e6677d

Browse files
author
jarvis
committed
refactor(identity): split UserService into focused partial files
Split the 682-line UserService.cs into smaller, focused partial class files: - UserService.cs (~80 lines) - Core class with constructor and shared helpers - UserService.Registration.cs (~220 lines) - RegisterAsync, GetOrCreateFromPrincipalAsync - UserService.Profile.cs (~100 lines) - GetAsync, GetListAsync, UpdateAsync, existence checks - UserService.Lifecycle.cs (~115 lines) - DeleteAsync, ToggleStatusAsync - UserService.Roles.cs (~85 lines) - AssignRolesAsync, GetUserRolesAsync - UserService.Confirmation.cs (~50 lines) - ConfirmEmailAsync, ConfirmPhoneNumberAsync Existing partial files kept unchanged: - UserService.Password.cs (87 lines) - UserService.Permissions.cs (53 lines) No interface or method signature changes. Build verified with 0 errors, 0 warnings.
1 parent fb16d34 commit 1e6677d

6 files changed

Lines changed: 643 additions & 601 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using FSH.Framework.Core.Exceptions;
2+
using Microsoft.AspNetCore.WebUtilities;
3+
using Microsoft.EntityFrameworkCore;
4+
using System.Globalization;
5+
using System.Text;
6+
7+
namespace FSH.Modules.Identity.Services;
8+
9+
internal sealed partial class UserService
10+
{
11+
public async Task<string> ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken)
12+
{
13+
EnsureValidTenant();
14+
15+
var user = await userManager.Users
16+
.Where(u => u.Id == userId && !u.EmailConfirmed)
17+
.FirstOrDefaultAsync(cancellationToken);
18+
19+
_ = user ?? throw new CustomException("An error occurred while confirming E-Mail.");
20+
21+
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
22+
var result = await userManager.ConfirmEmailAsync(user, code);
23+
24+
return result.Succeeded
25+
? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email)
26+
: throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email));
27+
}
28+
29+
public async Task<string> ConfirmPhoneNumberAsync(string userId, string code)
30+
{
31+
EnsureValidTenant();
32+
33+
var user = await userManager.Users
34+
.Where(u => u.Id == userId && !u.PhoneNumberConfirmed)
35+
.FirstOrDefaultAsync();
36+
37+
_ = user ?? throw new CustomException("An error occurred while confirming phone number.");
38+
39+
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
40+
var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code);
41+
42+
return result.Succeeded
43+
? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber)
44+
: throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber));
45+
}
46+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using FSH.Framework.Core.Exceptions;
2+
using FSH.Framework.Shared.Constants;
3+
using FSH.Modules.Auditing.Contracts;
4+
using Microsoft.EntityFrameworkCore;
5+
6+
namespace FSH.Modules.Identity.Services;
7+
8+
internal sealed partial class UserService
9+
{
10+
public async Task DeleteAsync(string userId)
11+
{
12+
var user = await userManager.FindByIdAsync(userId);
13+
14+
_ = user ?? throw new NotFoundException("User Not Found.");
15+
16+
user.IsActive = false;
17+
var result = await userManager.UpdateAsync(user);
18+
19+
if (!result.Succeeded)
20+
{
21+
var errors = result.Errors.Select(error => error.Description).ToList();
22+
throw new CustomException("Delete profile failed", errors);
23+
}
24+
}
25+
26+
public async Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken)
27+
{
28+
EnsureValidTenant();
29+
30+
var actorId = _currentUser.GetUserId();
31+
if (actorId == Guid.Empty)
32+
{
33+
throw new UnauthorizedException("authenticated user required to toggle status");
34+
}
35+
36+
var actor = await userManager.FindByIdAsync(actorId.ToString());
37+
_ = actor ?? throw new UnauthorizedException("current user not found");
38+
39+
async ValueTask AuditPolicyFailureAsync(string reason, CancellationToken ct)
40+
{
41+
var tenant = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id ?? "unknown";
42+
var claims = new Dictionary<string, object?>
43+
{
44+
["actorId"] = actorId.ToString(),
45+
["targetUserId"] = userId,
46+
["tenant"] = tenant,
47+
["action"] = activateUser ? "activate" : "deactivate"
48+
};
49+
50+
await _auditClient.WriteSecurityAsync(
51+
SecurityAction.PolicyFailed,
52+
subjectId: actorId.ToString(),
53+
reasonCode: reason,
54+
claims: claims,
55+
severity: AuditSeverity.Warning,
56+
source: "Identity",
57+
ct: ct).ConfigureAwait(false);
58+
}
59+
60+
if (!await userManager.IsInRoleAsync(actor, RoleConstants.Admin))
61+
{
62+
await AuditPolicyFailureAsync("ActorNotAdmin", cancellationToken);
63+
throw new CustomException("Only administrators can toggle user status.");
64+
}
65+
66+
if (!activateUser && string.Equals(actor.Id, userId, StringComparison.Ordinal))
67+
{
68+
await AuditPolicyFailureAsync("SelfDeactivationBlocked", cancellationToken);
69+
throw new CustomException("Users cannot deactivate themselves.");
70+
}
71+
72+
var user = await userManager.Users.Where(u => u.Id == userId).FirstOrDefaultAsync(cancellationToken);
73+
_ = user ?? throw new NotFoundException("User Not Found.");
74+
75+
bool targetIsAdmin = await userManager.IsInRoleAsync(user, RoleConstants.Admin);
76+
if (targetIsAdmin)
77+
{
78+
await AuditPolicyFailureAsync("AdminDeactivationBlocked", cancellationToken);
79+
throw new CustomException("Administrators cannot be deactivated.");
80+
}
81+
82+
if (!activateUser)
83+
{
84+
var activeAdmins = await userManager.GetUsersInRoleAsync(RoleConstants.Admin);
85+
int activeAdminCount = activeAdmins.Count(u => u.IsActive);
86+
if (activeAdminCount == 0)
87+
{
88+
await AuditPolicyFailureAsync("NoActiveAdmins", cancellationToken);
89+
throw new CustomException("Tenant must have at least one active administrator.");
90+
}
91+
}
92+
93+
var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id;
94+
if (activateUser)
95+
{
96+
user.Activate(actorId.ToString(), tenantId);
97+
}
98+
else
99+
{
100+
user.Deactivate(actorId.ToString(), "Status toggled by administrator", tenantId);
101+
}
102+
103+
var result = await userManager.UpdateAsync(user);
104+
if (!result.Succeeded)
105+
{
106+
var errors = result.Errors.Select(error => error.Description).ToList();
107+
throw new CustomException("Toggle status failed", errors);
108+
}
109+
110+
await _auditClient.WriteActivityAsync(
111+
ActivityKind.Command,
112+
name: "ToggleUserStatus",
113+
statusCode: 204,
114+
durationMs: 0,
115+
captured: BodyCapture.None,
116+
requestSize: 0,
117+
responseSize: 0,
118+
requestPreview: new { actorId = actorId.ToString(), targetUserId = userId, action = activateUser ? "activate" : "deactivate", tenant = tenantId ?? "unknown" },
119+
responsePreview: new { outcome = "success" },
120+
severity: AuditSeverity.Information,
121+
source: "Identity",
122+
ct: cancellationToken).ConfigureAwait(false);
123+
}
124+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using FSH.Framework.Core.Exceptions;
2+
using FSH.Framework.Shared.Storage;
3+
using FSH.Framework.Storage;
4+
using FSH.Modules.Identity.Contracts.DTOs;
5+
using FSH.Modules.Identity.Domain;
6+
using Microsoft.EntityFrameworkCore;
7+
8+
namespace FSH.Modules.Identity.Services;
9+
10+
internal sealed partial class UserService
11+
{
12+
public async Task<UserDto> GetAsync(string userId, CancellationToken cancellationToken)
13+
{
14+
var user = await userManager.Users
15+
.AsNoTracking()
16+
.Where(u => u.Id == userId)
17+
.FirstOrDefaultAsync(cancellationToken);
18+
19+
_ = user ?? throw new NotFoundException("user not found");
20+
21+
return new UserDto
22+
{
23+
Id = user.Id,
24+
Email = user.Email,
25+
UserName = user.UserName,
26+
FirstName = user.FirstName,
27+
LastName = user.LastName,
28+
ImageUrl = ResolveImageUrl(user.ImageUrl),
29+
IsActive = user.IsActive
30+
};
31+
}
32+
33+
public Task<int> GetCountAsync(CancellationToken cancellationToken) =>
34+
userManager.Users.AsNoTracking().CountAsync(cancellationToken);
35+
36+
public async Task<List<UserDto>> GetListAsync(CancellationToken cancellationToken)
37+
{
38+
var users = await userManager.Users.AsNoTracking().ToListAsync(cancellationToken);
39+
var result = new List<UserDto>(users.Count);
40+
foreach (var user in users)
41+
{
42+
result.Add(new UserDto
43+
{
44+
Id = user.Id,
45+
Email = user.Email,
46+
UserName = user.UserName,
47+
FirstName = user.FirstName,
48+
LastName = user.LastName,
49+
ImageUrl = ResolveImageUrl(user.ImageUrl),
50+
IsActive = user.IsActive
51+
});
52+
}
53+
54+
return result;
55+
}
56+
57+
public async Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage)
58+
{
59+
var user = await userManager.FindByIdAsync(userId);
60+
61+
_ = user ?? throw new NotFoundException("user not found");
62+
63+
Uri imageUri = user.ImageUrl ?? null!;
64+
if (image.Data != null || deleteCurrentImage)
65+
{
66+
var imageString = await storageService.UploadAsync<FshUser>(image, FileType.Image);
67+
user.ImageUrl = new Uri(imageString, UriKind.RelativeOrAbsolute);
68+
if (deleteCurrentImage && imageUri != null)
69+
{
70+
await storageService.RemoveAsync(imageUri.ToString());
71+
}
72+
}
73+
74+
user.FirstName = firstName;
75+
user.LastName = lastName;
76+
string? currentPhoneNumber = await userManager.GetPhoneNumberAsync(user);
77+
if (phoneNumber != currentPhoneNumber)
78+
{
79+
await userManager.SetPhoneNumberAsync(user, phoneNumber);
80+
}
81+
82+
var result = await userManager.UpdateAsync(user);
83+
await signInManager.RefreshSignInAsync(user);
84+
85+
if (!result.Succeeded)
86+
{
87+
throw new CustomException("Update profile failed");
88+
}
89+
}
90+
91+
public async Task<bool> ExistsWithEmailAsync(string email, string? exceptId = null)
92+
{
93+
EnsureValidTenant();
94+
return await userManager.FindByEmailAsync(email.Normalize()) is FshUser user && user.Id != exceptId;
95+
}
96+
97+
public async Task<bool> ExistsWithNameAsync(string name)
98+
{
99+
EnsureValidTenant();
100+
return await userManager.FindByNameAsync(name) is not null;
101+
}
102+
103+
public async Task<bool> ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null)
104+
{
105+
EnsureValidTenant();
106+
return await userManager.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber) is FshUser user && user.Id != exceptId;
107+
}
108+
}

0 commit comments

Comments
 (0)