shoc-backend/SeaHaven.Services/Implementation/TeamMemberInviteService.cs

133 lines
5.2 KiB
C#
Raw Normal View History

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 };
}