mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
A token carries the user's roles and account, so a demoted admin kept admin claims until the token expired. The team member update and the admin user edit now rotate the security stamp and evict the cached value when the role, account or user name changes. Permission overrides are read per request and are not in the token.
435 lines
18 KiB
C#
435 lines
18 KiB
C#
using System.Net.Mail;
|
|
using System.Security.Claims;
|
|
using Data.SeaHavenIndustries;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.Constants;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation;
|
|
|
|
public sealed class TeamMemberService : ITeamMemberService
|
|
{
|
|
private const string ActiveStatus = "Active";
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly RoleManager<IdentityRole> _roleManager;
|
|
private readonly IUserServiceAreaDataService _areaDataService;
|
|
private readonly ITeamPermissionOverrideDataService _permissionDataService;
|
|
private readonly IUserDataService _userDataService;
|
|
private readonly ITeamPermissionService _permissionService;
|
|
private readonly ITeamMemberInviteService _inviteService;
|
|
private readonly ISessionStampService _sessionStamps;
|
|
|
|
public TeamMemberService(
|
|
UserManager<ApplicationUser> userManager,
|
|
RoleManager<IdentityRole> roleManager,
|
|
IUserServiceAreaDataService areaDataService,
|
|
ITeamPermissionOverrideDataService permissionDataService,
|
|
IUserDataService userDataService,
|
|
ITeamPermissionService permissionService,
|
|
ITeamMemberInviteService inviteService,
|
|
ISessionStampService sessionStamps)
|
|
{
|
|
_sessionStamps = sessionStamps;
|
|
_userManager = userManager;
|
|
_roleManager = roleManager;
|
|
_areaDataService = areaDataService;
|
|
_permissionDataService = permissionDataService;
|
|
_userDataService = userDataService;
|
|
_permissionService = permissionService;
|
|
_inviteService = inviteService;
|
|
}
|
|
|
|
public async Task<CreateTeamMemberOutcomeDTO> CreateAsync(
|
|
CreateTeamMemberRequestDTO request,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (caller?.IsInRole("Admin") != true)
|
|
return Failure("Forbidden");
|
|
|
|
var validation = Validate(request, out var role, out var color, out var email, out var areas, out var overrides);
|
|
if (validation is not null)
|
|
return Failure(validation);
|
|
|
|
if (await _userManager.FindByEmailAsync(email!) is not null)
|
|
return Failure("Email is already in use.");
|
|
|
|
var now = DateTime.UtcNow;
|
|
var user = new ApplicationUser
|
|
{
|
|
UserName = email,
|
|
Email = email,
|
|
EmailConfirmed = false,
|
|
FirstName = request.Name!.Trim(),
|
|
Contact = request.Phone?.Trim(),
|
|
PhoneNumber = request.Phone?.Trim(),
|
|
Color = color,
|
|
UniqueName = ActiveStatus,
|
|
CreatedDate = now,
|
|
PendingRegistration = true,
|
|
PendingRegistrationCreatedDate = now
|
|
};
|
|
|
|
IssuedTeamMemberInvite? invite = null;
|
|
try
|
|
{
|
|
await _userDataService.ExecuteTransactionalAsync(
|
|
async transactionCancellationToken =>
|
|
{
|
|
var roleError = await EnsureRoleAsync(role!);
|
|
if (roleError is not null)
|
|
throw new TeamMemberCreateException(roleError);
|
|
|
|
IdentityResult createResult;
|
|
try
|
|
{
|
|
createResult = await _userManager.CreateAsync(user);
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
// A concurrent create won the race on the unique user-name index
|
|
// between the FindByEmailAsync check above and this insert. Surface
|
|
// the same conflict message instead of letting the database
|
|
// exception bubble up as a 500.
|
|
throw new TeamMemberCreateException("Email is already in use.");
|
|
}
|
|
|
|
if (!createResult.Succeeded)
|
|
throw new TeamMemberCreateException(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");
|
|
|
|
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
|
|
if (!addRoleResult.Succeeded)
|
|
throw new TeamMemberCreateException(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to assign role.");
|
|
|
|
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
|
|
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
|
|
invite = await _inviteService.CreateAsync(user.Id, transactionCancellationToken);
|
|
},
|
|
cancellationToken);
|
|
}
|
|
catch (TeamMemberCreateException exception)
|
|
{
|
|
return Failure(exception.Message);
|
|
}
|
|
|
|
// The member and their invite commit together; the email goes out only after
|
|
// commit, so a rolled-back member never receives a link.
|
|
var inviteEmailSent = await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
|
|
|
|
return new CreateTeamMemberOutcomeDTO
|
|
{
|
|
Success = true,
|
|
Member = new TeamMemberCreatedDTO
|
|
{
|
|
Id = user.Id,
|
|
Name = user.FirstName!,
|
|
Role = role!,
|
|
Color = color!,
|
|
Email = user.Email,
|
|
Phone = user.PhoneNumber,
|
|
ServiceAreas = areas!,
|
|
PendingRegistration = true,
|
|
InviteEmailSent = inviteEmailSent
|
|
}
|
|
};
|
|
}
|
|
|
|
public async Task<TeamMemberOperationOutcomeDTO> GetAsync(
|
|
string userId,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (caller?.IsInRole("Admin") != true)
|
|
return OperationFailure("Forbidden");
|
|
|
|
var user = await _userManager.FindByIdAsync(userId);
|
|
if (user is null)
|
|
return OperationFailure("Team member not found.");
|
|
|
|
return await OperationSuccessAsync(user, caller, cancellationToken);
|
|
}
|
|
|
|
public async Task<TeamMemberOperationOutcomeDTO> UpdateAsync(
|
|
string userId,
|
|
UpdateTeamMemberRequestDTO request,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (caller?.IsInRole("Admin") != true)
|
|
return OperationFailure("Forbidden");
|
|
|
|
var user = await _userManager.FindByIdAsync(userId);
|
|
if (user is null)
|
|
return OperationFailure("Team member not found.");
|
|
|
|
var existingRoles = await _userManager.GetRolesAsync(user);
|
|
var existingRole = existingRoles.FirstOrDefault();
|
|
var candidate = new CreateTeamMemberRequestDTO
|
|
{
|
|
Name = request.Name,
|
|
Role = request.Role,
|
|
Color = request.Color,
|
|
Email = request.Email,
|
|
Phone = request.Phone,
|
|
ServiceAreas = request.ServiceAreas,
|
|
PermissionOverrides = request.PermissionOverrides
|
|
};
|
|
var validation = Validate(candidate, out var role, out var color, out var email, out var areas, out var overrides);
|
|
if (validation is not null)
|
|
return OperationFailure(validation);
|
|
|
|
var roleChanged = !string.Equals(existingRole, role, StringComparison.OrdinalIgnoreCase);
|
|
var isOwner = await _userDataService.IsAccountOwnerAsync(user.Id, cancellationToken);
|
|
if (isOwner && (roleChanged || !request.IsActive))
|
|
return OperationFailure("Forbidden");
|
|
|
|
var duplicate = await _userManager.FindByEmailAsync(email!);
|
|
if (duplicate is not null && !string.Equals(duplicate.Id, user.Id, StringComparison.Ordinal))
|
|
return OperationFailure("Email is already in use.");
|
|
|
|
var roleError = await EnsureRoleAsync(role!);
|
|
if (roleError is not null)
|
|
return OperationFailure(roleError);
|
|
|
|
ApplyName(user, candidate.Name!.Trim());
|
|
user.Contact = candidate.Phone?.Trim();
|
|
user.PhoneNumber = candidate.Phone?.Trim();
|
|
user.Color = color;
|
|
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
|
|
var activeChanged = request.IsActive == (user.IsDeleted == true);
|
|
var deactivated = activeChanged && !request.IsActive;
|
|
user.IsDeleted = !request.IsActive;
|
|
|
|
var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
|
|
if (emailChanged)
|
|
{
|
|
var emailResult = await _userManager.SetEmailAsync(user, email!);
|
|
if (!emailResult.Succeeded)
|
|
return OperationFailure(emailResult.Errors.FirstOrDefault()?.Description ?? "Unable to update email.");
|
|
|
|
var userNameResult = await _userManager.SetUserNameAsync(user, email!);
|
|
if (!userNameResult.Succeeded)
|
|
return OperationFailure(userNameResult.Errors.FirstOrDefault()?.Description ?? "Unable to update email.");
|
|
|
|
var securityStampResult = await _userManager.UpdateSecurityStampAsync(user);
|
|
if (!securityStampResult.Succeeded)
|
|
return OperationFailure(securityStampResult.Errors.FirstOrDefault()?.Description ?? "Unable to update email.");
|
|
}
|
|
|
|
if (roleChanged)
|
|
{
|
|
if (existingRoles.Count > 0)
|
|
{
|
|
var removeRoleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles);
|
|
if (!removeRoleResult.Succeeded)
|
|
return OperationFailure(removeRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role.");
|
|
}
|
|
|
|
var addRoleResult = await _userManager.AddToRoleAsync(user, role!);
|
|
if (!addRoleResult.Succeeded)
|
|
return OperationFailure(addRoleResult.Errors.FirstOrDefault()?.Description ?? "Unable to update role.");
|
|
}
|
|
|
|
// A new stamp ends the member's earlier sessions: a deactivated member's stay
|
|
// ended if the member is reactivated later, and a member whose role changed
|
|
// signs in again to get a token with the new role.
|
|
if (deactivated || roleChanged)
|
|
user.SecurityStamp = Guid.NewGuid().ToString("N");
|
|
|
|
await _userDataService.UpdateUserAsync(user, cancellationToken);
|
|
if (activeChanged || emailChanged || roleChanged)
|
|
_sessionStamps.Forget(user.Id);
|
|
await _areaDataService.ReplaceAsync(user.Id, areas!, cancellationToken);
|
|
if (roleChanged || request.PermissionOverrides is not null)
|
|
{
|
|
await _permissionDataService.ClearOverridesAsync(user.Id, cancellationToken);
|
|
if (request.PermissionOverrides is not null)
|
|
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
|
|
}
|
|
|
|
// An invite sent to the old address, and any code confirmed through it, must not register the new one.
|
|
bool? inviteEmailSent = null;
|
|
if (emailChanged && user.PendingRegistration == true)
|
|
inviteEmailSent = await _inviteService.ReissueAsync(user, cancellationToken);
|
|
|
|
return await OperationSuccessAsync(user, caller, cancellationToken, inviteEmailSent);
|
|
}
|
|
|
|
private static void ApplyName(ApplicationUser user, string fullName)
|
|
{
|
|
var current = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
|
|
if (string.Equals(fullName, current, StringComparison.Ordinal))
|
|
return;
|
|
|
|
var separator = fullName.IndexOf(' ');
|
|
if (separator < 0)
|
|
{
|
|
user.FirstName = fullName;
|
|
user.LastName = null;
|
|
return;
|
|
}
|
|
|
|
user.FirstName = fullName[..separator].Trim();
|
|
user.LastName = fullName[(separator + 1)..].Trim();
|
|
}
|
|
|
|
private async Task<string?> EnsureRoleAsync(string role)
|
|
{
|
|
if (await _roleManager.RoleExistsAsync(role))
|
|
return null;
|
|
|
|
var roleResult = await _roleManager.CreateAsync(new IdentityRole(role));
|
|
return roleResult.Succeeded
|
|
? null
|
|
: roleResult.Errors.FirstOrDefault()?.Description ?? "Unable to create role.";
|
|
}
|
|
|
|
private async Task<TeamMemberOperationOutcomeDTO> OperationSuccessAsync(
|
|
ApplicationUser user,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken,
|
|
bool? inviteEmailSent = null)
|
|
{
|
|
var roles = await _userManager.GetRolesAsync(user);
|
|
var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken);
|
|
var areas = await _areaDataService.GetForUserAsync(user.Id, cancellationToken);
|
|
return new TeamMemberOperationOutcomeDTO
|
|
{
|
|
Success = true,
|
|
Member = new TeamMemberDetailDTO
|
|
{
|
|
Id = user.Id,
|
|
Name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(),
|
|
Role = TeamMemberConstants.CanonicalRole(roles.FirstOrDefault()) ?? roles.FirstOrDefault() ?? "",
|
|
Color = user.Color ?? "",
|
|
Email = user.Email,
|
|
Phone = user.Contact ?? user.PhoneNumber,
|
|
ServiceAreas = areas,
|
|
PendingRegistration = user.PendingRegistration == true,
|
|
InviteEmailSent = inviteEmailSent,
|
|
Status = user.UniqueName ?? "Active",
|
|
IsAccountOwner = user.IsAccountOwner,
|
|
Permissions = profile?.Value?.Permissions ?? Array.Empty<TeamPermissionValueDTO>()
|
|
}
|
|
};
|
|
}
|
|
|
|
private static string? Validate(
|
|
CreateTeamMemberRequestDTO request,
|
|
out string? role,
|
|
out string? color,
|
|
out string? email,
|
|
out IReadOnlyList<string>? areas,
|
|
out IReadOnlyDictionary<string, UserPermissionState>? overrides)
|
|
{
|
|
role = TeamMemberConstants.CanonicalRole(request.Role);
|
|
color = request.Color?.Trim();
|
|
email = request.Email?.Trim();
|
|
areas = NormalizeAreas(request.ServiceAreas, out var invalidArea);
|
|
overrides = NormalizeOverrides(request.PermissionOverrides, out var invalidPermission);
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
|
return "Name is required.";
|
|
if (role is null)
|
|
return "Role must be Dispatcher, Scheduler, or Admin.";
|
|
if (color is null || !TeamMemberConstants.Colors.Contains(color))
|
|
return "Color must be selected from the accessible palette.";
|
|
if (!IsValidEmail(email))
|
|
return "A valid email is required.";
|
|
if (invalidArea is not null)
|
|
return $"Unknown service area: {invalidArea}.";
|
|
if (string.Equals(role, "Dispatcher", StringComparison.Ordinal)
|
|
&& (areas is null || areas.Count == 0))
|
|
return "At least one service area is required for a Dispatcher.";
|
|
if (!string.Equals(role, "Dispatcher", StringComparison.Ordinal)
|
|
&& areas is { Count: > 0 })
|
|
return "Service areas are only available for Dispatchers.";
|
|
if (invalidPermission is not null)
|
|
return $"Unknown permission key: {invalidPermission}.";
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IReadOnlyList<string> NormalizeAreas(
|
|
IReadOnlyList<string>? values,
|
|
out string? invalidArea)
|
|
{
|
|
invalidArea = null;
|
|
var normalized = new List<string>();
|
|
foreach (var value in values ?? Array.Empty<string>())
|
|
{
|
|
var canonical = TeamMemberConstants.CanonicalArea(value);
|
|
if (canonical is null)
|
|
{
|
|
invalidArea = value;
|
|
return Array.Empty<string>();
|
|
}
|
|
|
|
if (!normalized.Contains(canonical, StringComparer.Ordinal))
|
|
normalized.Add(canonical);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private static IReadOnlyDictionary<string, UserPermissionState> NormalizeOverrides(
|
|
IReadOnlyDictionary<string, UserPermissionState>? values,
|
|
out string? invalidPermission)
|
|
{
|
|
invalidPermission = null;
|
|
var normalized = new Dictionary<string, UserPermissionState>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var pair in values ?? new Dictionary<string, UserPermissionState>())
|
|
{
|
|
var canonical = TeamPermissionKeys.All.FirstOrDefault(key =>
|
|
string.Equals(key, pair.Key, StringComparison.OrdinalIgnoreCase));
|
|
if (canonical is null)
|
|
{
|
|
invalidPermission = pair.Key;
|
|
return new Dictionary<string, UserPermissionState>();
|
|
}
|
|
|
|
if (!Enum.IsDefined(pair.Value))
|
|
{
|
|
invalidPermission = pair.Key;
|
|
return new Dictionary<string, UserPermissionState>();
|
|
}
|
|
|
|
normalized[canonical] = pair.Value;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private static bool IsValidEmail(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var address = new MailAddress(value);
|
|
return string.Equals(address.Address, value, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static CreateTeamMemberOutcomeDTO Failure(string error) =>
|
|
new() { Success = false, Error = error };
|
|
|
|
private sealed class TeamMemberCreateException : Exception
|
|
{
|
|
public TeamMemberCreateException(string error) : base(error)
|
|
{
|
|
}
|
|
}
|
|
|
|
private static TeamMemberOperationOutcomeDTO OperationFailure(string error) =>
|
|
new() { Success = false, Error = error };
|
|
}
|