Add init link.
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
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 const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IInvitationService _invitations;
|
||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
IAuthService authService, IInvitationService invitations,
|
||||||
|
UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_invitations = invitations;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_env = env;
|
_env = env;
|
||||||
}
|
}
|
||||||
@@ -186,6 +190,45 @@ public class AuthController : ControllerBase
|
|||||||
return NoContent();
|
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
|
// 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!;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
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<Member> Members => Set<Member>();
|
||||||
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||||
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||||
@@ -56,6 +57,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Ignore(e => e.IsActive);
|
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) ──────────────
|
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||||
builder.Entity<AppUser>(entity =>
|
builder.Entity<AppUser>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ public static class AuditActions
|
|||||||
public const string PasswordChanged = "PasswordChanged";
|
public const string PasswordChanged = "PasswordChanged";
|
||||||
public const string UserDeactivated = "UserDeactivated";
|
public const string UserDeactivated = "UserDeactivated";
|
||||||
public const string PermissionChanged = "PermissionChanged";
|
public const string PermissionChanged = "PermissionChanged";
|
||||||
|
public const string InvitationCreated = "InvitationCreated";
|
||||||
|
public const string InvitationAccepted = "InvitationAccepted";
|
||||||
public const string CheckIssued = "CheckIssued";
|
public const string CheckIssued = "CheckIssued";
|
||||||
public const string CheckVoided = "CheckVoided";
|
public const string CheckVoided = "CheckVoided";
|
||||||
public const string ExpenseApproved = "ExpenseApproved";
|
public const string ExpenseApproved = "ExpenseApproved";
|
||||||
@@ -56,7 +58,8 @@ public static class AuditActions
|
|||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
[
|
[
|
||||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||||
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
|
PasswordChanged, UserDeactivated, PermissionChanged,
|
||||||
|
InvitationCreated, InvitationAccepted, CheckIssued,
|
||||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
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");
|
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 =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
@@ -2024,6 +2069,17 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("Role");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("RefreshTokens");
|
b.Navigation("RefreshTokens");
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
|||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IMemberService, MemberService>();
|
builder.Services.AddScoped<IMemberService, MemberService>();
|
||||||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||||
|
builder.Services.AddScoped<IInvitationService, InvitationService>();
|
||||||
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||||
builder.Services.AddScoped<IGivingService, GivingService>();
|
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||||
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ public class AuthService : IAuthService
|
|||||||
throw new UnauthorizedAccessException("Account is inactive.");
|
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 roles = await _userManager.GetRolesAsync(user);
|
||||||
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
||||||
var rawRefresh = _tokenService.GenerateRefreshToken();
|
var rawRefresh = _tokenService.GenerateRefreshToken();
|
||||||
@@ -79,12 +95,6 @@ public class AuthService : IAuthService
|
|||||||
await _userManager.UpdateAsync(user);
|
await _userManager.UpdateAsync(user);
|
||||||
await _db.SaveChangesAsync();
|
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);
|
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ public interface IAuthService
|
|||||||
string rawRefreshToken,
|
string rawRefreshToken,
|
||||||
string? ipAddress = null);
|
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>
|
/// <summary>
|
||||||
/// Revokes the refresh token identified by its raw value.
|
/// Revokes the refresh token identified by its raw value.
|
||||||
/// Silently succeeds if the token is not found.
|
/// 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('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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 { 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 { 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 = [
|
export const routes: Routes = [
|
||||||
// Public routes
|
// Public routes
|
||||||
{ path: 'login', component: LoginPage },
|
{ 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).
|
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
||||||
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
||||||
|
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
|
||||||
|
<h1 class="text-xl font-semibold mb-1">River Of Life Christian Church</h1>
|
||||||
|
|
||||||
|
<!-- Validating the link -->
|
||||||
|
<ng-container *ngIf="step === 'loading'">
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<kendo-loader></kendo-loader>
|
||||||
|
<p class="mt-2 text-gray-600">Checking your invitation…</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Invalid / expired link -->
|
||||||
|
<ng-container *ngIf="step === 'invalid'">
|
||||||
|
<p class="text-base font-medium mb-2">This invitation can't be used</p>
|
||||||
|
<p class="text-gray-600 mb-4">{{ invalidMessage }}</p>
|
||||||
|
<button kendoButton themeColor="primary" (click)="goToLogin()">Go to sign in</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Set password form -->
|
||||||
|
<ng-container *ngIf="step === 'form'">
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Welcome<span *ngIf="memberName">, <strong>{{ memberName }}</strong></span>. Set a password to
|
||||||
|
finish creating your account and sign in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="grid grid-cols-1 gap-y-3">
|
||||||
|
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="New Password *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="newPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
|
||||||
|
Must be at least 8 characters with an uppercase letter, a lowercase letter,
|
||||||
|
a digit, and a special character.
|
||||||
|
</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="Confirm Password *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="confirmPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
|
||||||
|
Passwords do not match.
|
||||||
|
</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<p *ngIf="errorMessage" class="k-color-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<button kendoButton themeColor="primary" type="submit" [disabled]="form.invalid || submitting">
|
||||||
|
<span *ngIf="submitting">…</span>
|
||||||
|
Set password & sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
<kendo-dialog title="Invitation Link" (close)="onClose()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||||
|
|
||||||
|
<!-- Ask for an email when the member has none on file -->
|
||||||
|
<ng-container *ngIf="step === 'needEmail'">
|
||||||
|
<p class="k-mb-4">
|
||||||
|
Create a first-login invitation for <strong>{{ memberName }}</strong>.
|
||||||
|
This member has no email on file — enter one to use as their login.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form [formGroup]="emailForm" (ngSubmit)="generate()" class="k-form k-form-vertical">
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="Login Email *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="email"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['required']">Email is required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="emailForm.get('email')?.errors?.['email']">Invalid email address.</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p *ngIf="errorMessage" class="k-color-error k-mt-3">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="onClose()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" (click)="generate()">Create Link</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Generating spinner -->
|
||||||
|
<ng-container *ngIf="step === 'generating'">
|
||||||
|
<div class="k-text-center k-p-4">
|
||||||
|
<kendo-loader></kendo-loader>
|
||||||
|
<p class="k-mt-2">Creating invitation link…</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Ready — show link to copy / email -->
|
||||||
|
<ng-container *ngIf="step === 'ready'">
|
||||||
|
<p class="k-mb-3">
|
||||||
|
Send this link to <strong>{{ memberName }}</strong>. They'll set their own password and sign in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="k-d-flex k-gap-2 k-align-items-center k-mb-2">
|
||||||
|
<kendo-textbox [value]="link" [readonly]="true" style="flex: 1"></kendo-textbox>
|
||||||
|
<button kendoButton (click)="copyLink()">{{ copied ? 'Copied!' : 'Copy' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="k-font-size-sm k-mb-3">
|
||||||
|
Single use — expires {{ expiresAt | date:'medium' }}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button kendoButton themeColor="info" (click)="sendEmail()" [disabled]="isSending">
|
||||||
|
<span *ngIf="isSending">…</span>
|
||||||
|
Send via email
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton themeColor="primary" (click)="onClose()">Done</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</kendo-dialog>
|
||||||
+106
@@ -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<void>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,13 +63,15 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
<kendo-grid-column title="Actions" [width]="210">
|
<kendo-grid-column title="Actions" [width]="290">
|
||||||
<ng-template kendoGridCellTemplate let-row>
|
<ng-template kendoGridCellTemplate let-row>
|
||||||
<div class="k-d-flex k-gap-2">
|
<div class="k-d-flex k-gap-2">
|
||||||
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
||||||
<button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button>
|
<button kendoButton size="small" themeColor="error" (click)="deleteMember(row)">Delete</button>
|
||||||
<button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info"
|
<button *ngIf="!row.linkedUserId" kendoButton size="small" themeColor="info"
|
||||||
(click)="openCreateUserDialog(row)">+ Account</button>
|
(click)="openCreateUserDialog(row)">+ Account</button>
|
||||||
|
<button *appHasPermission="['Users', 'write']" kendoButton size="small" themeColor="warning"
|
||||||
|
(click)="openInviteDialog(row)">Invite</button>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
@@ -92,3 +94,10 @@
|
|||||||
(created)="onUserCreated()"
|
(created)="onUserCreated()"
|
||||||
(cancelled)="closeCreateUserDialog()">
|
(cancelled)="closeCreateUserDialog()">
|
||||||
</app-create-user-dialog>
|
</app-create-user-dialog>
|
||||||
|
|
||||||
|
<!-- Invitation Link Dialog -->
|
||||||
|
<app-invitation-dialog
|
||||||
|
*ngIf="showInviteDialog && selectedMemberForInvite"
|
||||||
|
[member]="selectedMemberForInvite"
|
||||||
|
(cancelled)="closeInviteDialog()">
|
||||||
|
</app-invitation-dialog>
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
|||||||
import { MemberApiService } from '../../services/member-api.service';
|
import { MemberApiService } from '../../services/member-api.service';
|
||||||
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
|
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
|
||||||
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
|
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
|
||||||
|
import { InvitationDialogComponent } from '../../components/invitation-dialog/invitation-dialog.component';
|
||||||
import {
|
import {
|
||||||
MemberListItemDto, MemberDto, CreateMemberRequest,
|
MemberListItemDto, MemberDto, CreateMemberRequest,
|
||||||
PagedResult, memberDisplayName
|
PagedResult, memberDisplayName
|
||||||
} from '../../models/member.model';
|
} from '../../models/member.model';
|
||||||
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||||
|
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-members-page',
|
selector: 'app-members-page',
|
||||||
@@ -22,7 +24,8 @@ import { PageHeaderActionsDirective } from '../../../../shared/directives/page-h
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, GridModule, InputsModule,
|
CommonModule, FormsModule, GridModule, InputsModule,
|
||||||
ButtonsModule, IndicatorsModule, DropDownsModule,
|
ButtonsModule, IndicatorsModule, DropDownsModule,
|
||||||
MemberFormDialogComponent, CreateUserDialogComponent, PageHeaderActionsDirective,
|
MemberFormDialogComponent, CreateUserDialogComponent, InvitationDialogComponent,
|
||||||
|
PageHeaderActionsDirective, HasPermissionDirective,
|
||||||
],
|
],
|
||||||
templateUrl: './members-page.component.html',
|
templateUrl: './members-page.component.html',
|
||||||
styleUrls: ['./members-page.component.scss'],
|
styleUrls: ['./members-page.component.scss'],
|
||||||
@@ -46,8 +49,10 @@ export class MembersPageComponent implements OnInit {
|
|||||||
// Dialogs
|
// Dialogs
|
||||||
showMemberDialog = false;
|
showMemberDialog = false;
|
||||||
showCreateUserDialog = false;
|
showCreateUserDialog = false;
|
||||||
|
showInviteDialog = false;
|
||||||
editingMember: MemberDto | null = null;
|
editingMember: MemberDto | null = null;
|
||||||
selectedMemberForUser: MemberListItemDto | null = null;
|
selectedMemberForUser: MemberListItemDto | null = null;
|
||||||
|
selectedMemberForInvite: MemberListItemDto | null = null;
|
||||||
|
|
||||||
readonly memberDisplayName = memberDisplayName;
|
readonly memberDisplayName = memberDisplayName;
|
||||||
|
|
||||||
@@ -139,4 +144,18 @@ export class MembersPageComponent implements OnInit {
|
|||||||
this.closeCreateUserDialog();
|
this.closeCreateUserDialog();
|
||||||
this.loadData();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<CreateInvitationResult> {
|
||||||
|
return this.http.post<CreateInvitationResult>(this.endpoint, { memberId, email });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E-mail an already-generated link to the member. */
|
||||||
|
sendEmail(memberId: number, link: string): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.endpoint}/send`, { memberId, link });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,14 @@ export interface LoginResult {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Matches the C# ValidateInvitationResult DTO. */
|
||||||
|
export interface ValidateInvitationResult {
|
||||||
|
valid: boolean;
|
||||||
|
expired: boolean;
|
||||||
|
memberName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenVerificationResult {
|
export interface TokenVerificationResult {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
/** Constructed from JWT claims when using secret-link login. */
|
/** 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<ValidateInvitationResult> {
|
||||||
|
return this.http.get<ValidateInvitationResult>(
|
||||||
|
`${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<UserInfo> {
|
||||||
|
return this.http.post<ApiLoginResponse>(
|
||||||
|
`${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
|
* Clears in-memory auth state immediately, then fires a fire-and-forget
|
||||||
* POST to revoke the server-side refresh token cookie.
|
* POST to revoke the server-side refresh token cookie.
|
||||||
|
|||||||
Reference in New Issue
Block a user