mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
132 lines
5.2 KiB
C#
132 lines
5.2 KiB
C#
using System.Net;
|
|
using System.Security.Claims;
|
|
using Data.SeaHavenIndustries;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.Services.Configuration;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
|
|
namespace SeaHaven.Services.Implementation;
|
|
|
|
public sealed class TeamMemberInviteService : ITeamMemberInviteService
|
|
{
|
|
/// <summary>The prototype states no lifetime; seven days is the product decision.</summary>
|
|
public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7);
|
|
|
|
private readonly ITeamMemberInviteDataService _inviteDataService;
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly IEmailSender _emailSender;
|
|
private readonly FrontendOptions _frontendOptions;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<TeamMemberInviteService> _logger;
|
|
|
|
public TeamMemberInviteService(
|
|
ITeamMemberInviteDataService inviteDataService,
|
|
UserManager<ApplicationUser> userManager,
|
|
IEmailSender emailSender,
|
|
IOptions<FrontendOptions> frontendOptions,
|
|
TimeProvider timeProvider,
|
|
ILogger<TeamMemberInviteService> logger)
|
|
{
|
|
_inviteDataService = inviteDataService;
|
|
_userManager = userManager;
|
|
_emailSender = emailSender;
|
|
_frontendOptions = frontendOptions.Value;
|
|
_timeProvider = timeProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<IssuedTeamMemberInvite> CreateAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
|
|
|
var (invite, issued) = NewInvite(userId);
|
|
await _inviteDataService.AddAsync(invite, cancellationToken);
|
|
return issued;
|
|
}
|
|
|
|
public async Task<bool> SendAsync(
|
|
IssuedTeamMemberInvite invite,
|
|
string email,
|
|
string name,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(invite);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}";
|
|
var body =
|
|
$"<p>Hi {WebUtility.HtmlEncode(name)},</p>" +
|
|
"<p>You've been added to Seahaven. Set your password and confirm your email to finish creating your account.</p>" +
|
|
// clicktracking=off stops SendGrid rewriting the link, so the token never passes through its redirect.
|
|
$"<p><a clicktracking=off href=\"{WebUtility.HtmlEncode(link)}\">Finish registration</a></p>" +
|
|
$"<p>This link expires in {InviteLifetime.Days} days and can be used once.</p>";
|
|
|
|
var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body);
|
|
if (!sent)
|
|
_logger.LogWarning("Team member invite email could not be sent for invite expiring {ExpiresAt}.", invite.ExpiresAt);
|
|
|
|
return sent;
|
|
}
|
|
|
|
public async Task<TeamMemberInviteResendOutcomeDTO> ResendAsync(
|
|
string userId,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (caller?.IsInRole("Admin") != true)
|
|
return ResendFailure("Forbidden");
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var user = await _userManager.FindByIdAsync(userId);
|
|
if (user is null)
|
|
return ResendFailure("Team member not found.");
|
|
|
|
if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email))
|
|
return ResendFailure("Only pending team members can be re-invited.");
|
|
|
|
if (await ReissueAsync(user, cancellationToken) != true)
|
|
return ResendFailure("The invite could not be emailed. Try again.");
|
|
|
|
return new TeamMemberInviteResendOutcomeDTO { Success = true };
|
|
}
|
|
|
|
public async Task<bool?> ReissueAsync(ApplicationUser user, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(user);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(user.Email);
|
|
|
|
var (invite, issued) = NewInvite(user.Id);
|
|
await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken);
|
|
|
|
// A deactivated member cannot register; the admin re-sends the invite after reactivating them.
|
|
if (user.IsDeleted == true)
|
|
return null;
|
|
|
|
var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
|
|
return await SendAsync(issued, user.Email, name, cancellationToken);
|
|
}
|
|
|
|
private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId)
|
|
{
|
|
var now = NowUtc();
|
|
var token = TeamMemberInviteSecrets.NewToken();
|
|
var invite = new TeamMemberInvite
|
|
{
|
|
UserId = userId,
|
|
TokenHash = TeamMemberInviteSecrets.HashToken(token),
|
|
CreatedAt = now,
|
|
ExpiresAt = now.Add(InviteLifetime)
|
|
};
|
|
return (invite, new IssuedTeamMemberInvite(token, invite.ExpiresAt));
|
|
}
|
|
|
|
private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime;
|
|
|
|
private static TeamMemberInviteResendOutcomeDTO ResendFailure(string error) =>
|
|
new() { Success = false, Error = error };
|
|
}
|