From e53cea7a8215082ec562b8dcf825f57541a51964 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Wed, 24 Jun 2026 10:53:13 -0700 Subject: [PATCH] Add init link. --- API/ROLAC.API/Controllers/AuthController.cs | 45 +++- .../Controllers/InvitationsController.cs | 39 +++ .../DTOs/Invitations/InvitationDtos.cs | 56 +++++ API/ROLAC.API/Data/AppDbContext.cs | 20 +- API/ROLAC.API/Entities/Logging/AuditLog.cs | 5 +- API/ROLAC.API/Entities/UserInvitation.cs | 35 +++ .../Migrations/AppDbContextModelSnapshot.cs | 56 +++++ API/ROLAC.API/Program.cs | 1 + API/ROLAC.API/Services/AuthService.cs | 22 +- API/ROLAC.API/Services/IAuthService.cs | 10 + API/ROLAC.API/Services/IInvitationService.cs | 28 +++ API/ROLAC.API/Services/InvitationService.cs | 237 ++++++++++++++++++ APP/src/app/app.routes.ts | 4 + .../accept-invitation.component.ts | 158 ++++++++++++ .../invitation-dialog.component.html | 60 +++++ .../invitation-dialog.component.ts | 106 ++++++++ .../members-page/members-page.component.html | 11 +- .../members-page/members-page.component.ts | 21 +- .../services/invitation-api.service.ts | 30 +++ APP/src/app/shared/services/auth.service.ts | 38 +++ 20 files changed, 971 insertions(+), 11 deletions(-) create mode 100644 API/ROLAC.API/Controllers/InvitationsController.cs create mode 100644 API/ROLAC.API/DTOs/Invitations/InvitationDtos.cs create mode 100644 API/ROLAC.API/Entities/UserInvitation.cs create mode 100644 API/ROLAC.API/Services/IInvitationService.cs create mode 100644 API/ROLAC.API/Services/InvitationService.cs create mode 100644 APP/src/app/features/accept-invitation/accept-invitation.component.ts create mode 100644 APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.html create mode 100644 APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.ts create mode 100644 APP/src/app/features/members/services/invitation-api.service.ts diff --git a/API/ROLAC.API/Controllers/AuthController.cs b/API/ROLAC.API/Controllers/AuthController.cs index d0ef6d5..52f4451 100644 --- a/API/ROLAC.API/Controllers/AuthController.cs +++ b/API/ROLAC.API/Controllers/AuthController.cs @@ -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 _userManager; private readonly IWebHostEnvironment _env; public AuthController( - IAuthService authService, UserManager userManager, IWebHostEnvironment env) + IAuthService authService, IInvitationService invitations, + UserManager 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=... + // ------------------------------------------------------------------------- + + /// + /// 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. + /// + [HttpGet("invitation/validate")] + [AllowAnonymous] + [ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)] + public async Task ValidateInvitation([FromQuery] string token) + => Ok(await _invitations.ValidateAsync(token)); + + // ------------------------------------------------------------------------- + // POST /api/auth/accept-invitation + // ------------------------------------------------------------------------- + + /// + /// 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. + /// + [HttpPost("accept-invitation")] + [AllowAnonymous] + [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task 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 // ------------------------------------------------------------------------- diff --git a/API/ROLAC.API/Controllers/InvitationsController.cs b/API/ROLAC.API/Controllers/InvitationsController.cs new file mode 100644 index 0000000..919d3ff --- /dev/null +++ b/API/ROLAC.API/Controllers/InvitationsController.cs @@ -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; + +/// +/// Admin endpoints for generating and e-mailing first-login invitation links. +/// The public consume/validate endpoints live on so they can set the +/// refresh-token cookie and stay anonymous. +/// +[ApiController] +[Route("api/invitations")] +[Authorize] +public class InvitationsController : ControllerBase +{ + private readonly IInvitationService _invitations; + public InvitationsController(IInvitationService invitations) => _invitations = invitations; + + /// POST /api/invitations — generate a link for a member; returns { token, expiresAt }. + [HttpPost] + [HasPermission(Modules.Users, PermissionActions.Write)] + public async Task Create([FromBody] CreateInvitationRequest request) + { + try { return Ok(await _invitations.CreateAsync(request)); } + catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); } + } + + /// POST /api/invitations/send — e-mail an already-generated link to the member. + [HttpPost("send")] + [HasPermission(Modules.Users, PermissionActions.Write)] + public async Task Send([FromBody] SendInvitationRequest request) + { + try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); } + catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); } + } +} diff --git a/API/ROLAC.API/DTOs/Invitations/InvitationDtos.cs b/API/ROLAC.API/DTOs/Invitations/InvitationDtos.cs new file mode 100644 index 0000000..22217d4 --- /dev/null +++ b/API/ROLAC.API/DTOs/Invitations/InvitationDtos.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; + +namespace ROLAC.API.DTOs.Invitations; + +/// +/// 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 or the member's email. +/// +public class CreateInvitationRequest +{ + [Required] + public int MemberId { get; set; } + + /// Optional override for the login email when the member has none on file. + public string? Email { get; set; } + + /// Roles to assign when an account is created. Defaults to ["member"]. + public List? Roles { get; set; } +} + +/// Result of generating an invitation — the raw token is returned ONCE. +public class CreateInvitationResult +{ + public string Token { get; set; } = null!; + public DateTime ExpiresAt { get; set; } +} + +/// Admin request to e-mail an already-generated invitation link to the member. +public class SendInvitationRequest +{ + [Required] + public int MemberId { get; set; } + + [Required] + public string Link { get; set; } = null!; +} + +/// Public result describing whether an invitation token can still be used. +public class ValidateInvitationResult +{ + public bool Valid { get; set; } + public bool Expired { get; set; } + public string? MemberName { get; set; } + public string? Email { get; set; } +} + +/// Public request to consume an invitation and set the account password. +public class AcceptInvitationRequest +{ + [Required] + public string Token { get; set; } = null!; + + [Required] + [StringLength(128, MinimumLength = 8)] + public string NewPassword { get; set; } = null!; +} diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index e8e7338..ebbc83b 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -10,7 +10,8 @@ public class AppDbContext : IdentityDbContext { public AppDbContext(DbContextOptions options) : base(options) { } - public DbSet RefreshTokens => Set(); + public DbSet RefreshTokens => Set(); + public DbSet UserInvitations => Set(); public DbSet Members => Set(); public DbSet FamilyUnits => Set(); public DbSet GivingCategories => Set(); @@ -56,6 +57,23 @@ public class AppDbContext : IdentityDbContext entity.Ignore(e => e.IsActive); }); + // ── UserInvitation (single-use, expiring first-login links) ───────── + builder.Entity(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(entity => { diff --git a/API/ROLAC.API/Entities/Logging/AuditLog.cs b/API/ROLAC.API/Entities/Logging/AuditLog.cs index f4bcafa..ca8e765 100644 --- a/API/ROLAC.API/Entities/Logging/AuditLog.cs +++ b/API/ROLAC.API/Entities/Logging/AuditLog.cs @@ -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 All = [ Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, - PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued, + PasswordChanged, UserDeactivated, PermissionChanged, + InvitationCreated, InvitationAccepted, CheckIssued, CheckVoided, ExpenseApproved, StatementFinalized, ]; } diff --git a/API/ROLAC.API/Entities/UserInvitation.cs b/API/ROLAC.API/Entities/UserInvitation.cs new file mode 100644 index 0000000..ed3a613 --- /dev/null +++ b/API/ROLAC.API/Entities/UserInvitation.cs @@ -0,0 +1,35 @@ +namespace ROLAC.API.Entities; + +/// +/// 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). +/// +public class UserInvitation +{ + public int Id { get; set; } + + public string UserId { get; set; } = null!; + public AppUser User { get; set; } = null!; + + /// SHA-256 hex of the raw invitation token. Never store raw tokens. + public string TokenHash { get; set; } = null!; + + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } + + /// Id of the admin who generated the link. + public string CreatedBy { get; set; } = null!; + + /// Set when the member consumes the link to set their password (single-use). + public DateTime? UsedAt { get; set; } + + /// Set when superseded by a newer invitation for the same user (re-issue). + 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; +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index e5f3945..ea9a3b4 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -1803,6 +1803,51 @@ namespace ROLAC.API.Migrations b.ToTable("SiteSettings"); }); + modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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", 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"); diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index f0c720e..4c0ee6d 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -144,6 +144,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/ROLAC.API/Services/AuthService.cs b/API/ROLAC.API/Services/AuthService.cs index db0abad..72f8b87 100644 --- a/API/ROLAC.API/Services/AuthService.cs +++ b/API/ROLAC.API/Services/AuthService.cs @@ -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); } diff --git a/API/ROLAC.API/Services/IAuthService.cs b/API/ROLAC.API/Services/IAuthService.cs index 39f77ad..5d8b0b5 100644 --- a/API/ROLAC.API/Services/IAuthService.cs +++ b/API/ROLAC.API/Services/IAuthService.cs @@ -25,6 +25,16 @@ public interface IAuthService string rawRefreshToken, string? ipAddress = null); + /// + /// 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. + /// + Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync( + AppUser user, + string? ipAddress = null, + string? deviceInfo = null); + /// /// Revokes the refresh token identified by its raw value. /// Silently succeeds if the token is not found. diff --git a/API/ROLAC.API/Services/IInvitationService.cs b/API/ROLAC.API/Services/IInvitationService.cs new file mode 100644 index 0000000..b863d1b --- /dev/null +++ b/API/ROLAC.API/Services/IInvitationService.cs @@ -0,0 +1,28 @@ +using ROLAC.API.DTOs.Invitations; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public interface IInvitationService +{ + /// + /// 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 when the member is missing or has no email. + /// + Task CreateAsync(CreateInvitationRequest request); + + /// Checks whether a raw token is still usable, without mutating it. + Task ValidateAsync(string rawToken); + + /// + /// 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). + /// + Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword); + + /// E-mails an already-generated invitation link to the member via IEmailService. + Task SendEmailAsync(int memberId, string link); +} diff --git a/API/ROLAC.API/Services/InvitationService.cs b/API/ROLAC.API/Services/InvitationService.cs new file mode 100644 index 0000000..6da36c3 --- /dev/null +++ b/API/ROLAC.API/Services/InvitationService.cs @@ -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 +{ + /// Lifetime of a freshly issued invitation link. + private const int InvitationLifetimeDays = 7; + + private readonly UserManager _userManager; + private readonly AppDbContext _db; + private readonly ITokenService _tokenService; + private readonly IEmailService _emailService; + private readonly IAuditLogger _audit; + private readonly CurrentUserAccessor _currentUser; + + public InvitationService( + UserManager 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 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 }; + } + + /// Creates a passwordless login account linked to the member; mirrors UserManagementService. + private async Task CreateAccountAsync(Member member, string email, List? 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 { "member" }; + await _userManager.AddToRolesAsync(user, rolesToAssign); + + return user; + } + + // ── Validate ─────────────────────────────────────────────────────────────── + + public async Task 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 = + $"

Hi {name},

" + + "

You've been invited to set up your account for the River Of Life Christian Church portal.

" + + $"

Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.

" + + $"

Set your password and sign in

" + + "

If the button doesn't work, copy and paste this address into your browser:

" + + $"

{safeLink}

"; + + var result = await _emailService.SendAsync(new EmailMessage( + MemberIds: new[] { memberId }, + Addresses: Array.Empty(), + 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 FindByRawTokenAsync(string rawToken) + { + if (string.IsNullOrWhiteSpace(rawToken)) + return Task.FromResult(null); + + var hash = _tokenService.HashToken(rawToken); + return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash); + } + + private async Task 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(); + } + + /// 32 cryptographically-random bytes as a URL-safe base64 string. + private static string GenerateRawToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } +} diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index b11a04f..9602005 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -24,11 +24,15 @@ import { OfferingEntryMobilePageComponent } from './features/giving/pages/offeri import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component'; import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component'; import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component'; +import { AcceptInvitationComponent } from './features/accept-invitation/accept-invitation.component'; export const routes: Routes = [ // Public routes { path: 'login', component: LoginPage }, + // Public first-login page — member sets their own password from a secret invitation link. + { path: 'accept-invitation', component: AcceptInvitationComponent }, + // Public Sunday meal attendance counter — no login required (volunteers on phones). { path: 'attendance', component: AttendanceCounterPageComponent }, diff --git a/APP/src/app/features/accept-invitation/accept-invitation.component.ts b/APP/src/app/features/accept-invitation/accept-invitation.component.ts new file mode 100644 index 0000000..6f4c439 --- /dev/null +++ b/APP/src/app/features/accept-invitation/accept-invitation.component.ts @@ -0,0 +1,158 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { IndicatorsModule } from '@progress/kendo-angular-indicators'; +import { AuthService } from '../../shared/services/auth.service'; +import { + passwordStrengthValidator, + passwordMatchValidator, +} from '../account/validators/password.validators'; + +type Step = 'loading' | 'invalid' | 'form'; + +@Component({ + selector: 'app-accept-invitation', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, + InputsModule, LabelModule, ButtonsModule, IndicatorsModule, + ], + template: ` +
+
+ +

River Of Life Christian Church

+ + + +
+ +

Checking your invitation…

+
+
+ + + +

This invitation can't be used

+

{{ invalidMessage }}

+ +
+ + + +

+ Welcome, {{ memberName }}. Set a password to + finish creating your account and sign in. +

+ +
+
+ + + + + Required. + + Must be at least 8 characters with an uppercase letter, a lowercase letter, + a digit, and a special character. + + + + + + + Required. + + Passwords do not match. + + + +

{{ errorMessage }}

+ +
+ +
+ +
+
+
+ +
+
+ `, +}) +export class AcceptInvitationComponent implements OnInit { + step: Step = 'loading'; + form: FormGroup; + submitting = false; + memberName: string | null = null; + invalidMessage = 'This invitation link is invalid or has already been used.'; + errorMessage = ''; + + private token = ''; + + constructor( + private fb: FormBuilder, + private auth: AuthService, + private route: ActivatedRoute, + private router: Router, + ) { + this.form = this.fb.group( + { + newPassword: ['', [Validators.required, passwordStrengthValidator()]], + confirmPassword: ['', [Validators.required]], + }, + { validators: passwordMatchValidator() }, + ); + } + + ngOnInit(): void { + this.token = this.route.snapshot.queryParamMap.get('token') ?? ''; + if (!this.token) { + this.step = 'invalid'; + return; + } + + this.auth.validateInvitation(this.token).subscribe({ + next: (result) => { + if (result.valid) { + this.memberName = result.memberName ?? null; + this.step = 'form'; + } else { + this.invalidMessage = result.expired + ? 'This invitation link has expired. Please ask for a new one.' + : 'This invitation link is invalid or has already been used.'; + this.step = 'invalid'; + } + }, + error: () => { this.step = 'invalid'; }, + }); + } + + onSubmit(): void { + if (this.form.invalid) { this.form.markAllAsTouched(); return; } + this.submitting = true; + this.errorMessage = ''; + + this.auth.acceptInvitation(this.token, this.form.value.newPassword).subscribe({ + next: () => { + this.router.navigate(['/user-portal/dashboard']); + }, + error: (err) => { + this.errorMessage = err.error?.message ?? 'Could not set your password. The link may have expired.'; + this.submitting = false; + }, + }); + } + + goToLogin(): void { + this.router.navigate(['/login']); + } +} diff --git a/APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.html b/APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.html new file mode 100644 index 0000000..aa45a56 --- /dev/null +++ b/APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.html @@ -0,0 +1,60 @@ + + + + +

+ Create a first-login invitation for {{ memberName }}. + This member has no email on file — enter one to use as their login. +

+ +
+ + + + Email is required. + Invalid email address. + +
+ +

{{ errorMessage }}

+ + + + + +
+ + + +
+ +

Creating invitation link…

+
+
+ + + +

+ Send this link to {{ memberName }}. They'll set their own password and sign in. +

+ +
+ + +
+ +

+ Single use — expires {{ expiresAt | date:'medium' }}. +

+ + + + + + +
+ +
diff --git a/APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.ts b/APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.ts new file mode 100644 index 0000000..8751604 --- /dev/null +++ b/APP/src/app/features/members/components/invitation-dialog/invitation-dialog.component.ts @@ -0,0 +1,106 @@ +import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { IndicatorsModule } from '@progress/kendo-angular-indicators'; +import { MemberListItemDto, memberDisplayName } from '../../models/member.model'; +import { InvitationApiService } from '../../services/invitation-api.service'; +import { ToastService } from '../../../../core/services/toast.service'; + +type Step = 'needEmail' | 'generating' | 'ready'; + +@Component({ + selector: 'app-invitation-dialog', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, DialogsModule, InputsModule, + LabelModule, ButtonsModule, IndicatorsModule, + ], + templateUrl: './invitation-dialog.component.html', +}) +export class InvitationDialogComponent implements OnInit { + @Input({ required: true }) member!: MemberListItemDto; + @Output() cancelled = new EventEmitter(); + + step: Step = 'generating'; + emailForm!: FormGroup; + + link = ''; + expiresAt: string | null = null; + copied = false; + isSending = false; + errorMessage = ''; + + get memberName(): string { return memberDisplayName(this.member); } + + constructor( + private fb: FormBuilder, + private invitationApi: InvitationApiService, + private toast: ToastService, + ) {} + + ngOnInit(): void { + this.emailForm = this.fb.group({ + email: [this.member.email ?? '', [Validators.required, Validators.email]], + }); + + // Auto-generate when the member already has an email; otherwise ask for one first. + if (this.member.email) { + this.generate(); + } else { + this.step = 'needEmail'; + } + } + + /** Generate (or re-issue) the link. Uses the form email when the member has none on file. */ + generate(): void { + if (this.step === 'needEmail') { + if (this.emailForm.invalid) { this.emailForm.markAllAsTouched(); return; } + } + + const email = this.member.email ? undefined : this.emailForm.value.email; + this.step = 'generating'; + this.errorMessage = ''; + + this.invitationApi.create(this.member.id, email).subscribe({ + next: (result) => { + this.link = `${window.location.origin}/accept-invitation?token=${result.token}`; + this.expiresAt = result.expiresAt; + this.step = 'ready'; + }, + error: (err) => { + this.errorMessage = err.error?.message ?? 'Failed to create the invitation link.'; + // Fall back to the email step so the admin can supply/correct an address. + this.step = 'needEmail'; + }, + }); + } + + copyLink(): void { + navigator.clipboard.writeText(this.link).then(() => { + this.copied = true; + setTimeout(() => (this.copied = false), 2000); + }); + } + + sendEmail(): void { + this.isSending = true; + this.invitationApi.sendEmail(this.member.id, this.link).subscribe({ + next: () => { + this.toast.success(`Invitation emailed to ${this.memberName}.`); + this.isSending = false; + }, + error: (err) => { + this.toast.error(err.error?.message ?? 'Failed to send the email.'); + this.isSending = false; + }, + }); + } + + onClose(): void { + this.cancelled.emit(); + } +} diff --git a/APP/src/app/features/members/pages/members-page/members-page.component.html b/APP/src/app/features/members/pages/members-page/members-page.component.html index 292bcdc..b68d4e0 100644 --- a/APP/src/app/features/members/pages/members-page/members-page.component.html +++ b/APP/src/app/features/members/pages/members-page/members-page.component.html @@ -63,13 +63,15 @@ - +
+
@@ -92,3 +94,10 @@ (created)="onUserCreated()" (cancelled)="closeCreateUserDialog()"> + + + + diff --git a/APP/src/app/features/members/pages/members-page/members-page.component.ts b/APP/src/app/features/members/pages/members-page/members-page.component.ts index 86b78ef..28d23f1 100644 --- a/APP/src/app/features/members/pages/members-page/members-page.component.ts +++ b/APP/src/app/features/members/pages/members-page/members-page.component.ts @@ -9,12 +9,14 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; import { MemberApiService } from '../../services/member-api.service'; import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component'; import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component'; +import { InvitationDialogComponent } from '../../components/invitation-dialog/invitation-dialog.component'; import { MemberListItemDto, MemberDto, CreateMemberRequest, PagedResult, memberDisplayName } from '../../models/member.model'; import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists'; import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive'; +import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive'; @Component({ selector: 'app-members-page', @@ -22,7 +24,8 @@ import { PageHeaderActionsDirective } from '../../../../shared/directives/page-h imports: [ CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, IndicatorsModule, DropDownsModule, - MemberFormDialogComponent, CreateUserDialogComponent, PageHeaderActionsDirective, + MemberFormDialogComponent, CreateUserDialogComponent, InvitationDialogComponent, + PageHeaderActionsDirective, HasPermissionDirective, ], templateUrl: './members-page.component.html', styleUrls: ['./members-page.component.scss'], @@ -46,8 +49,10 @@ export class MembersPageComponent implements OnInit { // Dialogs showMemberDialog = false; showCreateUserDialog = false; + showInviteDialog = false; editingMember: MemberDto | null = null; selectedMemberForUser: MemberListItemDto | null = null; + selectedMemberForInvite: MemberListItemDto | null = null; readonly memberDisplayName = memberDisplayName; @@ -139,4 +144,18 @@ export class MembersPageComponent implements OnInit { this.closeCreateUserDialog(); this.loadData(); } + + // ── Invitation Link ───────────────────────────────────────────────────────── + + openInviteDialog(member: MemberListItemDto): void { + this.selectedMemberForInvite = member; + this.showInviteDialog = true; + } + + closeInviteDialog(): void { + this.showInviteDialog = false; + this.selectedMemberForInvite = null; + // An invitation may have just created an account, so refresh the grid. + this.loadData(); + } } diff --git a/APP/src/app/features/members/services/invitation-api.service.ts b/APP/src/app/features/members/services/invitation-api.service.ts new file mode 100644 index 0000000..106bf65 --- /dev/null +++ b/APP/src/app/features/members/services/invitation-api.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; + +export interface CreateInvitationResult { + /** Raw, single-use token — returned once; build the link from it client-side. */ + token: string; + /** ISO timestamp when the link stops working. */ + expiresAt: string; +} + +@Injectable({ providedIn: 'root' }) +export class InvitationApiService { + private readonly endpoint: string; + + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('invitations'); + } + + /** Generate (or re-issue) a first-login invitation link for a member. */ + create(memberId: number, email?: string): Observable { + return this.http.post(this.endpoint, { memberId, email }); + } + + /** E-mail an already-generated link to the member. */ + sendEmail(memberId: number, link: string): Observable { + return this.http.post(`${this.endpoint}/send`, { memberId, link }); + } +} diff --git a/APP/src/app/shared/services/auth.service.ts b/APP/src/app/shared/services/auth.service.ts index b14c5a2..c269772 100644 --- a/APP/src/app/shared/services/auth.service.ts +++ b/APP/src/app/shared/services/auth.service.ts @@ -64,6 +64,14 @@ export interface LoginResult { message?: string; } +/** Matches the C# ValidateInvitationResult DTO. */ +export interface ValidateInvitationResult { + valid: boolean; + expired: boolean; + memberName?: string; + email?: string; +} + export interface TokenVerificationResult { isValid: boolean; /** Constructed from JWT claims when using secret-link login. */ @@ -177,6 +185,36 @@ export class AuthService { ); } + /** + * Checks whether an invitation token is still usable (anonymous). Used by the + * public "set your password" page to decide what to show before the member types. + */ + validateInvitation(token: string): Observable { + return this.http.get( + `${this.apiConfig.authUrl}/invitation/validate`, + { params: { token } } + ); + } + + /** + * Consumes an invitation: sets the password and logs the member in. On success the + * server returns a normal login payload, so we store the access token + user (and the + * refresh cookie is set server-side) exactly like login(). Errors propagate to the caller. + */ + acceptInvitation(token: string, newPassword: string): Observable { + return this.http.post( + `${this.apiConfig.authUrl}/accept-invitation`, + { token, newPassword }, + { withCredentials: true } + ).pipe( + tap(response => { + this.accessToken$.next(response.accessToken); + this.currentUser$.next(response.user); + }), + map(response => response.user) + ); + } + /** * Clears in-memory auth state immediately, then fires a fire-and-forget * POST to revoke the server-side refresh token cookie.