diff --git a/BTCPayServer.Data/Data/ApplicationUser.cs b/BTCPayServer.Data/Data/ApplicationUser.cs index 9d046fb70..f6d2260c6 100644 --- a/BTCPayServer.Data/Data/ApplicationUser.cs +++ b/BTCPayServer.Data/Data/ApplicationUser.cs @@ -46,5 +46,6 @@ namespace BTCPayServer.Data public bool ShowInvoiceStatusChangeHint { get; set; } public string ImageUrl { get; set; } public string Name { get; set; } + public string InvitationToken { get; set; } } } diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 3d59c0b62..c05fc1a4f 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -371,7 +371,7 @@ namespace BTCPayServer.Tests var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com"; s.Driver.FindElement(By.Id("Email")).SendKeys(usr); s.ClickPagePrimary(); - var url = s.FindAlertMessage().FindElement(By.TagName("a")).Text; + var url = s.Driver.FindElement(By.Id("InvitationUrl")).GetAttribute("data-text"); s.Logout(); s.Driver.Navigate().GoToUrl(url); diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs index 3688770ab..662dfc184 100644 --- a/BTCPayServer/Controllers/UIAccountController.cs +++ b/BTCPayServer/Controllers/UIAccountController.cs @@ -812,7 +812,7 @@ namespace BTCPayServer.Controllers return NotFound(); } - var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code)); + var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code)); if (user == null) { return NotFound(); @@ -827,6 +827,9 @@ namespace BTCPayServer.Controllers RequestUri = Request.GetAbsoluteRootUri() }); + // unset used token + await _userManager.UnsetInvitationTokenAsync(user.Id); + if (requiresEmailConfirmation) { return await RedirectToConfirmEmail(user); diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs index 5e0fea2d5..f91e48b19 100644 --- a/BTCPayServer/Controllers/UIServerController.Users.cs +++ b/BTCPayServer/Controllers/UIServerController.Users.cs @@ -58,18 +58,28 @@ namespace BTCPayServer.Controllers .Skip(model.Skip) .Take(model.Count) .ToListAsync()) - .Select(u => new UsersViewModel.UserViewModel + .Select(u => { - Name = u.GetBlob()?.Name, - ImageUrl = u.GetBlob()?.ImageUrl, - Email = u.Email, - Id = u.Id, - EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null, - Approved = u.RequiresApproval ? u.Approved : null, - Created = u.Created, - Roles = u.UserRoles.Select(role => role.RoleId), - Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime, - Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList() + var blob = u.GetBlob(); + return new UsersViewModel.UserViewModel + { + Name = blob?.Name, + ImageUrl = blob?.ImageUrl, + Email = u.Email, + Id = u.Id, + InvitationUrl = + string.IsNullOrEmpty(blob?.InvitationToken) + ? null + : _linkGenerator.InvitationLink(u.Id, blob.InvitationToken, Request.Scheme, + Request.Host, Request.PathBase), + EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null, + Approved = u.RequiresApproval ? u.Approved : null, + Created = u.Created, + Roles = u.UserRoles.Select(role => role.RoleId), + Disabled = u.LockoutEnabled && u.LockoutEnd != null && + DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime, + Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList() + }; }) .ToList(); return View(model); @@ -88,6 +98,7 @@ namespace BTCPayServer.Controllers Id = user.Id, Email = user.Email, Name = blob?.Name, + InvitationUrl = string.IsNullOrEmpty(blob?.InvitationToken) ? null : _linkGenerator.InvitationLink(user.Id, blob.InvitationToken, Request.Scheme, Request.Host, Request.PathBase), ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)), EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null, Approved = user.RequiresApproval ? user.Approved : null, @@ -200,16 +211,20 @@ namespace BTCPayServer.Controllers } [HttpGet("server/users/new")] - public IActionResult CreateUser() + public async Task CreateUser() { - ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; - return View(); + await PrepareCreateUserViewData(); + var vm = new RegisterFromAdminViewModel + { + SendInvitationEmail = ViewData["CanSendEmail"] is true + }; + return View(vm); } [HttpPost("server/users/new")] public async Task CreateUser(RegisterFromAdminViewModel model) { - ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; + await PrepareCreateUserViewData(); if (!_Options.CheatMode) model.IsAdmin = false; if (ModelState.IsValid) @@ -236,6 +251,7 @@ namespace BTCPayServer.Controllers var tcs = new TaskCompletionSource(); var currentUser = await _UserManager.GetUserAsync(HttpContext.User); + var sendEmail = model.SendInvitationEmail && ViewData["CanSendEmail"] is true; _eventAggregator.Publish(new UserRegisteredEvent { @@ -243,23 +259,23 @@ namespace BTCPayServer.Controllers Kind = UserRegisteredEventKind.Invite, User = user, InvitedByUser = currentUser, + SendInvitationEmail = sendEmail, Admin = model.IsAdmin, CallbackUrlGenerated = tcs }); var callbackUrl = await tcs.Task; - var settings = await _SettingsRepository.GetSettingAsync() ?? new EmailSettings(); - var info = settings.IsComplete() - ? "An invitation email has been sent.
You may alternatively" - : "An invitation email has not been sent, because the server does not have an email server configured.
You need to"; + var info = sendEmail + ? "An invitation email has been sent. You may alternatively" + : "An invitation email has not been sent. You need to"; TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Success, AllowDismiss = false, - Html = $"Account successfully created. {info} share this link with them: {callbackUrl}" + Html = $"Account successfully created. {info} share this link with them:
{callbackUrl}" }); - return RedirectToAction(nameof(ListUsers)); + return RedirectToAction(nameof(User), new { userId = user.Id }); } foreach (var error in result.Errors) @@ -391,6 +407,13 @@ namespace BTCPayServer.Controllers TempData[WellKnownTempData.SuccessMessage] = "Verification email sent"; return RedirectToAction(nameof(ListUsers)); } + + private async Task PrepareCreateUserViewData() + { + var emailSettings = await _SettingsRepository.GetSettingAsync() ?? new EmailSettings(); + ViewData["CanSendEmail"] = emailSettings.IsComplete(); + ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail; + } } public class RegisterFromAdminViewModel @@ -415,5 +438,8 @@ namespace BTCPayServer.Controllers [Display(Name = "Email confirmed?")] public bool EmailConfirmed { get; set; } + + [Display(Name = "Send invitation email")] + public bool SendInvitationEmail { get; set; } = true; } } diff --git a/BTCPayServer/Events/UserRegisteredEvent.cs b/BTCPayServer/Events/UserRegisteredEvent.cs index fc8a0e36f..e27ea63c6 100644 --- a/BTCPayServer/Events/UserRegisteredEvent.cs +++ b/BTCPayServer/Events/UserRegisteredEvent.cs @@ -11,6 +11,7 @@ public class UserRegisteredEvent public UserRegisteredEventKind Kind { get; set; } = UserRegisteredEventKind.Registration; public Uri RequestUri { get; set; } public ApplicationUser InvitedByUser { get; set; } + public bool SendInvitationEmail { get; set; } public TaskCompletionSource CallbackUrlGenerated; } diff --git a/BTCPayServer/HostedServices/UserEventHostedService.cs b/BTCPayServer/HostedServices/UserEventHostedService.cs index f2d65936e..041eb873b 100644 --- a/BTCPayServer/HostedServices/UserEventHostedService.cs +++ b/BTCPayServer/HostedServices/UserEventHostedService.cs @@ -73,11 +73,12 @@ public class UserEventHostedService( emailSender = await emailSenderFactory.GetEmailSender(); if (isInvite) { - code = await userManager.GenerateInvitationTokenAsync(user); + code = await userManager.GenerateInvitationTokenAsync(user.Id); callbackUrl = generator.InvitationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery); ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl)); - emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl); + if (ev.SendInvitationEmail) + emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl); } else if (requiresEmailConfirmation) { diff --git a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs index eb386b380..907f2761a 100644 --- a/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs +++ b/BTCPayServer/Models/ServerViewModels/UsersViewModel.cs @@ -14,7 +14,8 @@ namespace BTCPayServer.Models.ServerViewModels public string Id { get; set; } public string Email { get; set; } public string Name { get; set; } - + [Display(Name = "Invitation URL")] + public string InvitationUrl { get; set; } [Display(Name = "Image")] public IFormFile ImageFile { get; set; } public string ImageUrl { get; set; } diff --git a/BTCPayServer/UserManagerExtensions.cs b/BTCPayServer/UserManagerExtensions.cs index 2f5acd387..08be237e1 100644 --- a/BTCPayServer/UserManagerExtensions.cs +++ b/BTCPayServer/UserManagerExtensions.cs @@ -1,5 +1,7 @@ #nullable enable +using System; using System.Threading.Tasks; +using BTCPayServer.Data; using BTCPayServer.Security; using Microsoft.AspNetCore.Identity; @@ -19,15 +21,35 @@ namespace BTCPayServer return await userManager.FindByIdAsync(idOrEmail); } - public static async Task GenerateInvitationTokenAsync(this UserManager userManager, TUser user) where TUser : class + public static async Task GenerateInvitationTokenAsync(this UserManager userManager, string userId) where TUser : class { - return await userManager.GenerateUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose); + var token = Guid.NewGuid().ToString("n")[..12]; + return await userManager.SetInvitationTokenAsync(userId, token) ? token : null; } - public static async Task FindByInvitationTokenAsync(this UserManager userManager, string userId, string token) where TUser : class + public static async Task UnsetInvitationTokenAsync(this UserManager userManager, string userId) where TUser : class + { + return await userManager.SetInvitationTokenAsync(userId, null); + } + + private static async Task SetInvitationTokenAsync(this UserManager userManager, string userId, string? token) where TUser : class { var user = await userManager.FindByIdAsync(userId); - var isValid = user is not null && await userManager.VerifyUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose, token); + if (user == null) return false; + var blob = user.GetBlob() ?? new UserBlob(); + blob.InvitationToken = token; + user.SetBlob(blob); + await userManager.UpdateAsync(user); + return true; + } + + public static async Task FindByInvitationTokenAsync(this UserManager userManager, string userId, string token) where TUser : class + { + var user = await userManager.FindByIdAsync(userId); + var isValid = user is not null && ( + user.GetBlob()?.InvitationToken == token || + // backwards-compatibility with old tokens + await userManager.VerifyUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose, token)); return isValid ? user : null; } } diff --git a/BTCPayServer/Views/UIServer/CreateUser.cshtml b/BTCPayServer/Views/UIServer/CreateUser.cshtml index 0f173c4c1..c294a7ef3 100644 --- a/BTCPayServer/Views/UIServer/CreateUser.cshtml +++ b/BTCPayServer/Views/UIServer/CreateUser.cshtml @@ -1,6 +1,9 @@ +@using BTCPayServer.TagHelpers +@using Microsoft.AspNetCore.Mvc.TagHelpers @model BTCPayServer.Controllers.RegisterFromAdminViewModel @{ ViewData.SetActivePage(ServerNavPages.Users, "Create account"); + var canSendEmail = ViewData["CanSendEmail"] is true; }
@@ -55,6 +58,17 @@ } +
+ +
+ + + @if (!canSendEmail) + { +
Your email server has not been configured. Please configure it first.
+ } +
+
diff --git a/BTCPayServer/Views/UIServer/ListUsers.cshtml b/BTCPayServer/Views/UIServer/ListUsers.cshtml index 12cc39cd5..0b7060bca 100644 --- a/BTCPayServer/Views/UIServer/ListUsers.cshtml +++ b/BTCPayServer/Views/UIServer/ListUsers.cshtml @@ -55,6 +55,7 @@ { Disabled: true } => ("Disabled", "danger"), { Approved: false } => ("Pending Approval", "warning"), { EmailConfirmed: false } => ("Pending Email Verification", "warning"), + { InvitationUrl: not null } => ("Pending Invitation", "warning"), _ => ("Active", "success") }; var detailsId = $"user_details_{user.Id}"; @@ -68,7 +69,7 @@ } - @user.Stores.Count() Store@(user.Stores.Count() == 1 ? "" : "s") + @user.Stores.Count() Store@(user.Stores.Count() == 1 ? "" : "s") @user.Created?.ToBrowserDate() @status.Item1 @@ -82,11 +83,11 @@ { Approve } + Edit @if (status.Item2 != "warning") { @(user.Disabled ? "Enable" : "Disable") } - Edit Remove @@ -100,7 +101,21 @@ - @if (user.Stores.Any()) + @if (!string.IsNullOrEmpty(user.InvitationUrl)) + { +
+
+ +
+
+
+ + +
+
+
+ } + else if (user.Stores.Any()) {
    @foreach (var store in user.Stores) @@ -118,7 +133,7 @@ } else { - No stores + No stores } diff --git a/BTCPayServer/Views/UIServer/User.cshtml b/BTCPayServer/Views/UIServer/User.cshtml index 66e7a9812..1365281c2 100644 --- a/BTCPayServer/Views/UIServer/User.cshtml +++ b/BTCPayServer/Views/UIServer/User.cshtml @@ -21,6 +21,22 @@ + + @if (!string.IsNullOrEmpty(Model.InvitationUrl)) + { +
    +
    + +
    +
    +
    + + +
    +
    +
    + } +