SendAsync(
IssuedTeamMemberInvite invite,
string email,
string name,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(invite);
cancellationToken.ThrowIfCancellationRequested();
var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}";
var body =
$"Hi {WebUtility.HtmlEncode(name)},
" +
"You've been added to Seahaven. Set your password and confirm your email to finish creating your account.
" +
// clicktracking=off stops SendGrid rewriting the link, so the token never passes through its redirect.
$"Finish registration
" +
$"This link expires in {InviteLifetime.Days} days and can be used once.
";
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 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 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 };
}