Add init link.
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.DTOs.Invitations;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -16,13 +17,16 @@ public class AuthController : ControllerBase
|
||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IInvitationService _invitations;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||
IAuthService authService, IInvitationService invitations,
|
||||
UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||
{
|
||||
_authService = authService;
|
||||
_invitations = invitations;
|
||||
_userManager = userManager;
|
||||
_env = env;
|
||||
}
|
||||
@@ -186,6 +190,45 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/invitation/validate?token=...
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an invitation token can still be used. Anonymous so the public
|
||||
/// "set your password" page can decide what to show before the member types anything.
|
||||
/// </summary>
|
||||
[HttpGet("invitation/validate")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
|
||||
=> Ok(await _invitations.ValidateAsync(token));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/auth/accept-invitation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Consumes an invitation: sets the account password and, on success, logs the member in
|
||||
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
|
||||
/// </summary>
|
||||
[HttpPost("accept-invitation")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
|
||||
{
|
||||
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
|
||||
if (user is null)
|
||||
return BadRequest(new { message = error });
|
||||
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var device = Request.Headers.UserAgent.FirstOrDefault();
|
||||
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
|
||||
SetRefreshCookie(raw);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Invitations;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for generating and e-mailing first-login invitation links.
|
||||
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
|
||||
/// refresh-token cookie and stay anonymous.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/invitations")]
|
||||
[Authorize]
|
||||
public class InvitationsController : ControllerBase
|
||||
{
|
||||
private readonly IInvitationService _invitations;
|
||||
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
|
||||
|
||||
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
|
||||
{
|
||||
try { return Ok(await _invitations.CreateAsync(request)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
|
||||
[HttpPost("send")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
|
||||
{
|
||||
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ROLAC.API.DTOs.Invitations;
|
||||
|
||||
/// <summary>
|
||||
/// Admin request to generate a first-login invitation link for a member. If the member has no
|
||||
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
|
||||
/// </summary>
|
||||
public class CreateInvitationRequest
|
||||
{
|
||||
[Required]
|
||||
public int MemberId { get; set; }
|
||||
|
||||
/// <summary>Optional override for the login email when the member has none on file.</summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
|
||||
public List<string>? Roles { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
|
||||
public class CreateInvitationResult
|
||||
{
|
||||
public string Token { get; set; } = null!;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
|
||||
public class SendInvitationRequest
|
||||
{
|
||||
[Required]
|
||||
public int MemberId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Link { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>Public result describing whether an invitation token can still be used.</summary>
|
||||
public class ValidateInvitationResult
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
public bool Expired { get; set; }
|
||||
public string? MemberName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Public request to consume an invitation and set the account password.</summary>
|
||||
public class AcceptInvitationRequest
|
||||
{
|
||||
[Required]
|
||||
public string Token { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = null!;
|
||||
}
|
||||
@@ -10,7 +10,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
|
||||
public DbSet<Member> Members => Set<Member>();
|
||||
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||
@@ -56,6 +57,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Ignore(e => e.IsActive);
|
||||
});
|
||||
|
||||
// ── UserInvitation (single-use, expiring first-login links) ─────────
|
||||
builder.Entity<UserInvitation>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.TokenHash).IsUnique();
|
||||
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
||||
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
|
||||
entity.HasIndex(e => e.UserId);
|
||||
entity.HasOne(e => e.User).WithMany()
|
||||
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.Ignore(e => e.IsExpired);
|
||||
entity.Ignore(e => e.IsUsed);
|
||||
entity.Ignore(e => e.IsRevoked);
|
||||
entity.Ignore(e => e.IsActive);
|
||||
});
|
||||
|
||||
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||
builder.Entity<AppUser>(entity =>
|
||||
{
|
||||
|
||||
@@ -48,6 +48,8 @@ public static class AuditActions
|
||||
public const string PasswordChanged = "PasswordChanged";
|
||||
public const string UserDeactivated = "UserDeactivated";
|
||||
public const string PermissionChanged = "PermissionChanged";
|
||||
public const string InvitationCreated = "InvitationCreated";
|
||||
public const string InvitationAccepted = "InvitationAccepted";
|
||||
public const string CheckIssued = "CheckIssued";
|
||||
public const string CheckVoided = "CheckVoided";
|
||||
public const string ExpenseApproved = "ExpenseApproved";
|
||||
@@ -56,7 +58,8 @@ public static class AuditActions
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
|
||||
PasswordChanged, UserDeactivated, PermissionChanged,
|
||||
InvitationCreated, InvitationAccepted, CheckIssued,
|
||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A single-use, expiring invitation that lets a member set their own password and log in for
|
||||
/// the first time — without an admin-generated temporary password. The raw token is e-mailed /
|
||||
/// copied to the member; only its SHA-256 hash is stored here (same scheme as RefreshToken).
|
||||
/// </summary>
|
||||
public class UserInvitation
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string UserId { get; set; } = null!;
|
||||
public AppUser User { get; set; } = null!;
|
||||
|
||||
/// <summary>SHA-256 hex of the raw invitation token. Never store raw tokens.</summary>
|
||||
public string TokenHash { get; set; } = null!;
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Id of the admin who generated the link.</summary>
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
|
||||
/// <summary>Set when the member consumes the link to set their password (single-use).</summary>
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
||||
/// <summary>Set when superseded by a newer invitation for the same user (re-issue).</summary>
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
|
||||
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
|
||||
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
|
||||
public bool IsUsed => UsedAt.HasValue;
|
||||
public bool IsRevoked => RevokedAt.HasValue;
|
||||
public bool IsActive => !IsUsed && !IsRevoked && !IsExpired;
|
||||
}
|
||||
@@ -1803,6 +1803,51 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("SiteSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserInvitations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||
@@ -2024,6 +2069,17 @@ namespace ROLAC.API.Migrations
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
|
||||
@@ -144,6 +144,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IMemberService, MemberService>();
|
||||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||
builder.Services.AddScoped<IInvitationService, InvitationService>();
|
||||
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||
|
||||
@@ -60,6 +60,22 @@ public class AuthService : IAuthService
|
||||
throw new UnauthorizedAccessException("Account is inactive.");
|
||||
}
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"Login succeeded: {user.Email}",
|
||||
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
|
||||
|
||||
return await IssueSessionAsync(user, ipAddress, deviceInfo);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Issue session (shared by login and passwordless flows like invitations)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
|
||||
AppUser user, string? ipAddress = null, string? deviceInfo = null)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
||||
var rawRefresh = _tokenService.GenerateRefreshToken();
|
||||
@@ -79,12 +95,6 @@ public class AuthService : IAuthService
|
||||
await _userManager.UpdateAsync(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"Login succeeded: {user.Email}",
|
||||
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
|
||||
|
||||
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,16 @@ public interface IAuthService
|
||||
string rawRefreshToken,
|
||||
string? ipAddress = null);
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh access token + refresh token for an already-verified user (no password
|
||||
/// check). Stores the refresh token and returns the raw value for the caller to put in the
|
||||
/// HttpOnly cookie. Used by passwordless flows such as accepting an invitation link.
|
||||
/// </summary>
|
||||
Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
|
||||
AppUser user,
|
||||
string? ipAddress = null,
|
||||
string? deviceInfo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Revokes the refresh token identified by its raw value.
|
||||
/// Silently succeeds if the token is not found.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using ROLAC.API.DTOs.Invitations;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IInvitationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a single-use, 7-day invitation link for a member. Auto-creates the member's
|
||||
/// login account (no password) when none exists, and revokes any prior unused invitation for
|
||||
/// that account. Returns the raw token (shown once) and its expiry.
|
||||
/// Throws <see cref="InvalidOperationException"/> when the member is missing or has no email.
|
||||
/// </summary>
|
||||
Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request);
|
||||
|
||||
/// <summary>Checks whether a raw token is still usable, without mutating it.</summary>
|
||||
Task<ValidateInvitationResult> ValidateAsync(string rawToken);
|
||||
|
||||
/// <summary>
|
||||
/// Consumes an invitation: validates the token, sets the account password (enforcing the
|
||||
/// Identity policy), and marks the invitation used. Returns the account on success, or an
|
||||
/// error message describing why it failed (invalid/expired/used token or a policy violation).
|
||||
/// </summary>
|
||||
Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword);
|
||||
|
||||
/// <summary>E-mails an already-generated invitation link to the member via IEmailService.</summary>
|
||||
Task SendEmailAsync(int memberId, string link);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Invitations;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public class InvitationService : IInvitationService
|
||||
{
|
||||
/// <summary>Lifetime of a freshly issued invitation link.</summary>
|
||||
private const int InvitationLifetimeDays = 7;
|
||||
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IAuditLogger _audit;
|
||||
private readonly CurrentUserAccessor _currentUser;
|
||||
|
||||
public InvitationService(
|
||||
UserManager<AppUser> userManager,
|
||||
AppDbContext db,
|
||||
ITokenService tokenService,
|
||||
IEmailService emailService,
|
||||
IAuditLogger audit,
|
||||
CurrentUserAccessor currentUser)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_db = db;
|
||||
_tokenService = tokenService;
|
||||
_emailService = emailService;
|
||||
_audit = audit;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request)
|
||||
{
|
||||
var member = await _db.Members.FindAsync(request.MemberId)
|
||||
?? throw new InvalidOperationException($"Member {request.MemberId} does not exist.");
|
||||
|
||||
var email = (request.Email ?? member.Email)?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
throw new InvalidOperationException(
|
||||
"This member has no email address. Add an email before creating an invitation.");
|
||||
|
||||
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.MemberId == request.MemberId);
|
||||
if (user is null)
|
||||
user = await CreateAccountAsync(member, email, request.Roles);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Re-issue: revoke any prior unused invitation so only one link is ever live.
|
||||
var existing = await _db.UserInvitations
|
||||
.Where(invitation => invitation.UserId == user.Id
|
||||
&& invitation.UsedAt == null
|
||||
&& invitation.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
foreach (var invitation in existing)
|
||||
invitation.RevokedAt = now;
|
||||
|
||||
var rawToken = GenerateRawToken();
|
||||
var expiresAt = now.AddDays(InvitationLifetimeDays);
|
||||
|
||||
_db.UserInvitations.Add(new UserInvitation
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = _tokenService.HashToken(rawToken),
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = now,
|
||||
CreatedBy = _currentUser.UserIdOrSystem,
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.InvitationCreated, AuditCategories.Security, LogLevelEnum.Information,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"Invitation link created for {user.Email}");
|
||||
|
||||
return new CreateInvitationResult { Token = rawToken, ExpiresAt = expiresAt };
|
||||
}
|
||||
|
||||
/// <summary>Creates a passwordless login account linked to the member; mirrors UserManagementService.</summary>
|
||||
private async Task<AppUser> CreateAccountAsync(Member member, string email, List<string>? roles)
|
||||
{
|
||||
if (await _userManager.FindByEmailAsync(email) is not null)
|
||||
throw new InvalidOperationException($"Email '{email}' is already in use by another account.");
|
||||
|
||||
var user = new AppUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
EmailConfirmed = true,
|
||||
MemberId = member.Id,
|
||||
LanguagePreference = member.LanguagePreference,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// No-password overload: the member sets their own password via the invitation link.
|
||||
var result = await _userManager.CreateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
throw new InvalidOperationException(
|
||||
string.Join("; ", result.Errors.Select(error => error.Description)));
|
||||
|
||||
var rolesToAssign = roles is { Count: > 0 } ? roles : new List<string> { "member" };
|
||||
await _userManager.AddToRolesAsync(user, rolesToAssign);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// ── Validate ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<ValidateInvitationResult> ValidateAsync(string rawToken)
|
||||
{
|
||||
var invitation = await FindByRawTokenAsync(rawToken);
|
||||
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
|
||||
return new ValidateInvitationResult { Valid = false, Expired = false };
|
||||
if (invitation.IsExpired)
|
||||
return new ValidateInvitationResult { Valid = false, Expired = true };
|
||||
|
||||
var user = await _userManager.FindByIdAsync(invitation.UserId);
|
||||
return new ValidateInvitationResult
|
||||
{
|
||||
Valid = true,
|
||||
Expired = false,
|
||||
Email = user?.Email,
|
||||
MemberName = await ResolveMemberNameAsync(user),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Accept ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword)
|
||||
{
|
||||
var invitation = await FindByRawTokenAsync(rawToken);
|
||||
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
|
||||
return (null, "This invitation link is invalid or has already been used.");
|
||||
if (invitation.IsExpired)
|
||||
return (null, "This invitation link has expired. Please ask for a new one.");
|
||||
|
||||
var user = await _userManager.FindByIdAsync(invitation.UserId);
|
||||
if (user is null)
|
||||
return (null, "The account for this invitation no longer exists.");
|
||||
|
||||
// Set the password — works whether or not one already exists, and enforces the policy.
|
||||
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var result = await _userManager.ResetPasswordAsync(user, resetToken, newPassword);
|
||||
if (!result.Succeeded)
|
||||
return (null, string.Join(" ", result.Errors.Select(error => error.Description)));
|
||||
|
||||
invitation.UsedAt = DateTime.UtcNow;
|
||||
user.EmailConfirmed = true;
|
||||
user.IsActive = true;
|
||||
await _userManager.UpdateAsync(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.InvitationAccepted, AuditCategories.Security, LogLevelEnum.Information,
|
||||
entityName: nameof(AppUser), entityId: user.Id,
|
||||
summary: $"Invitation accepted — password set for {user.Email}",
|
||||
userId: user.Id, userEmail: user.Email);
|
||||
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
// ── Send email ───────────────────────────────────────────────────────────
|
||||
|
||||
public async Task SendEmailAsync(int memberId, string link)
|
||||
{
|
||||
var member = await _db.Members.FindAsync(memberId)
|
||||
?? throw new InvalidOperationException($"Member {memberId} does not exist.");
|
||||
|
||||
var name = WebUtility.HtmlEncode(member.NickName ?? member.FirstName_en);
|
||||
var safeLink = WebUtility.HtmlEncode(link);
|
||||
var subject = "Your River Of Life Christian Church account invitation";
|
||||
var htmlBody =
|
||||
$"<p>Hi {name},</p>" +
|
||||
"<p>You've been invited to set up your account for the River Of Life Christian Church portal.</p>" +
|
||||
$"<p>Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.</p>" +
|
||||
$"<p><a href=\"{safeLink}\">Set your password and sign in</a></p>" +
|
||||
"<p>If the button doesn't work, copy and paste this address into your browser:</p>" +
|
||||
$"<p>{safeLink}</p>";
|
||||
|
||||
var result = await _emailService.SendAsync(new EmailMessage(
|
||||
MemberIds: new[] { memberId },
|
||||
Addresses: Array.Empty<string>(),
|
||||
Subject: subject,
|
||||
HtmlBody: htmlBody));
|
||||
|
||||
if (result.SentCount == 0)
|
||||
throw new InvalidOperationException(
|
||||
result.Failures.Count > 0
|
||||
? $"Failed to send email: {result.Failures[0].Error}"
|
||||
: "No email address on file for this member.");
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private Task<UserInvitation?> FindByRawTokenAsync(string rawToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawToken))
|
||||
return Task.FromResult<UserInvitation?>(null);
|
||||
|
||||
var hash = _tokenService.HashToken(rawToken);
|
||||
return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveMemberNameAsync(AppUser? user)
|
||||
{
|
||||
if (user?.MemberId is not int memberId)
|
||||
return null;
|
||||
|
||||
return await _db.Members
|
||||
.Where(member => member.Id == memberId)
|
||||
.Select(member => (member.NickName ?? member.FirstName_en) + " " + member.LastName_en)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>32 cryptographically-random bytes as a URL-safe base64 string.</summary>
|
||||
private static string GenerateRawToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user