mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-10 00:09:18 +01:00
Onboarding: Invite new users (#5714)
* Server Users: More precise message when inviting users This lets the admin who invited a new user know whether or not an email has been sent. If the SMTP server hasn't been set up, they need to share the invite link with the user. * Onboarding: Invite new users - Separates the user self-registration and invite cases - Adds invitation email for users created by the admin - Adds invitation tokens to verify user was invited - Adds handler action for invite links - Refactors `UserEventHostedService` * Remove duplicate status message from views that use the wizard layout * Auto-approve users created by an admin * Notify admins via email if a new account requires approval * Update wording * Fix update user error * Fix redirect to email confirmation in invite action * Fix precondition checks after signup * Improve admin notification Send notification only if the user does not require email confirmation or when they confirmed their email address. Rationale: We want to inform admins only about qualified users and not annoy them with bot registrations. * Allow approval alongside resending confirm email * Use user email in log messages instead of ID * Prevent unnecessary notification after email confirmation * Use ApplicationUser type explicitly * Fix after rebase * Refactoring: Do not subclass UserRegisteredEvent
This commit is contained in:
parent
8b446e2791
commit
e43b4ed540
24 changed files with 394 additions and 237 deletions
|
@ -349,17 +349,18 @@ namespace BTCPayServer.Tests
|
|||
s.GoToHome();
|
||||
|
||||
//Change Password & Log Out
|
||||
var newPassword = "abc???";
|
||||
s.GoToProfile(ManageNavPages.ChangePassword);
|
||||
s.Driver.FindElement(By.Id("OldPassword")).SendKeys("123456");
|
||||
s.Driver.FindElement(By.Id("NewPassword")).SendKeys("abc???");
|
||||
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("abc???");
|
||||
s.Driver.FindElement(By.Id("NewPassword")).SendKeys(newPassword);
|
||||
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys(newPassword);
|
||||
s.Driver.FindElement(By.Id("UpdatePassword")).Click();
|
||||
s.Logout();
|
||||
s.Driver.AssertNoError();
|
||||
|
||||
//Log In With New Password
|
||||
s.Driver.FindElement(By.Id("Email")).SendKeys(email);
|
||||
s.Driver.FindElement(By.Id("Password")).SendKeys("abc???");
|
||||
s.Driver.FindElement(By.Id("Password")).SendKeys(newPassword);
|
||||
s.Driver.FindElement(By.Id("LoginButton")).Click();
|
||||
|
||||
s.GoToHome();
|
||||
|
@ -383,11 +384,14 @@ namespace BTCPayServer.Tests
|
|||
s.Driver.Navigate().GoToUrl(url);
|
||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
|
||||
Assert.Equal(usr, s.Driver.FindElement(By.Id("Email")).GetAttribute("value"));
|
||||
Assert.Equal("Set your password", s.Driver.FindElement(By.CssSelector("h4")).Text);
|
||||
Assert.Contains("Invitation accepted. Please set your password.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info).Text);
|
||||
|
||||
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
||||
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
|
||||
s.Driver.FindElement(By.Id("SetPassword")).Click();
|
||||
s.FindAlertMessage();
|
||||
Assert.Contains("Password successfully set.", s.FindAlertMessage().Text);
|
||||
|
||||
s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
|
||||
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
||||
s.Driver.FindElement(By.Id("LoginButton")).Click();
|
||||
|
@ -428,11 +432,6 @@ namespace BTCPayServer.Tests
|
|||
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
|
||||
Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
|
||||
|
||||
// Check user create view has approval checkbox
|
||||
s.GoToServer(ServerNavPages.Users);
|
||||
s.Driver.FindElement(By.Id("CreateUser")).Click();
|
||||
Assert.False(s.Driver.FindElement(By.Id("Approved")).Selected);
|
||||
|
||||
// Ensure there is no unread notification yet
|
||||
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
|
||||
s.Logout();
|
||||
|
|
|
@ -185,7 +185,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
|
||||
RequiresApproval = policies.RequiresUserApproval,
|
||||
Created = DateTimeOffset.UtcNow,
|
||||
Approved = !anyAdmin && isAdmin // auto-approve first admin
|
||||
Approved = isAdmin // auto-approve first admin and users created by an admin
|
||||
};
|
||||
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
|
||||
if (!passwordValidation.Succeeded)
|
||||
|
@ -214,7 +214,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
if (request.IsAdministrator is true)
|
||||
var isNewAdmin = request.IsAdministrator is true;
|
||||
if (isNewAdmin)
|
||||
{
|
||||
if (!anyAdmin)
|
||||
{
|
||||
|
@ -233,7 +234,21 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration, Logs);
|
||||
}
|
||||
}
|
||||
_eventAggregator.Publish(new UserRegisteredEvent() { RequestUri = Request.GetAbsoluteRootUri(), User = user, Admin = request.IsAdministrator is true });
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var userEvent = new UserRegisteredEvent
|
||||
{
|
||||
RequestUri = Request.GetAbsoluteRootUri(),
|
||||
Admin = isNewAdmin,
|
||||
User = user
|
||||
};
|
||||
if (currentUser is not null)
|
||||
{
|
||||
userEvent.Kind = UserRegisteredEventKind.Invite;
|
||||
userEvent.InvitedByUser = currentUser;
|
||||
};
|
||||
_eventAggregator.Publish(userEvent);
|
||||
|
||||
var model = await FromModel(user);
|
||||
return CreatedAtAction(string.Empty, model);
|
||||
}
|
||||
|
|
|
@ -600,7 +600,6 @@ namespace BTCPayServer.Controllers
|
|||
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
|
||||
settings.FirstRun = false;
|
||||
await _SettingsRepository.UpdateSetting(settings);
|
||||
|
||||
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
|
||||
RegisteredAdmin = true;
|
||||
}
|
||||
|
@ -614,15 +613,17 @@ namespace BTCPayServer.Controllers
|
|||
RegisteredUserId = user.Id;
|
||||
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Account created.";
|
||||
if (policies.RequiresConfirmedEmail)
|
||||
var requiresConfirmedEmail = policies.RequiresConfirmedEmail && !user.EmailConfirmed;
|
||||
var requiresUserApproval = policies.RequiresUserApproval && !user.Approved;
|
||||
if (requiresConfirmedEmail)
|
||||
{
|
||||
TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email.";
|
||||
}
|
||||
if (policies.RequiresUserApproval)
|
||||
if (requiresUserApproval)
|
||||
{
|
||||
TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in.";
|
||||
}
|
||||
if (policies.RequiresConfirmedEmail || policies.RequiresUserApproval)
|
||||
if (requiresConfirmedEmail || requiresUserApproval)
|
||||
{
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
|
@ -670,25 +671,31 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
|
||||
var result = await _userManager.ConfirmEmailAsync(user, code);
|
||||
if (!await _userManager.HasPasswordAsync(user))
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_eventAggregator.Publish(new UserConfirmedEmailEvent
|
||||
{
|
||||
User = user,
|
||||
RequestUri = Request.GetAbsoluteRootUri()
|
||||
});
|
||||
|
||||
var hasPassword = await _userManager.HasPasswordAsync(user);
|
||||
if (hasPassword)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Message = "Your email has been confirmed."
|
||||
});
|
||||
return RedirectToAction(nameof(Login), new { email = user.Email });
|
||||
}
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
Message = "Your email has been confirmed but you still need to set your password."
|
||||
Message = "Your email has been confirmed. Please set your password."
|
||||
});
|
||||
return RedirectToAction("SetPassword", new { email = user.Email, code = await _userManager.GeneratePasswordResetTokenAsync(user) });
|
||||
}
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Message = "Your email has been confirmed."
|
||||
});
|
||||
return RedirectToAction("Login", new { email = user.Email });
|
||||
return await RedirectToSetPassword(user);
|
||||
}
|
||||
|
||||
return View("Error");
|
||||
|
@ -743,14 +750,20 @@ namespace BTCPayServer.Controllers
|
|||
throw new ApplicationException("A code must be supplied for password reset.");
|
||||
}
|
||||
|
||||
var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId);
|
||||
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
email = user?.Email;
|
||||
}
|
||||
|
||||
var model = new SetPasswordViewModel { Code = code, Email = email, EmailSetInternally = !string.IsNullOrEmpty(email) };
|
||||
return View(model);
|
||||
return View(new SetPasswordViewModel
|
||||
{
|
||||
Code = code,
|
||||
Email = email,
|
||||
EmailSetInternally = !string.IsNullOrEmpty(email),
|
||||
HasPassword = hasPassword
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/login/set-password")]
|
||||
|
@ -762,6 +775,7 @@ namespace BTCPayServer.Controllers
|
|||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByEmailAsync(model.Email);
|
||||
if (!UserService.TryCanLogin(user, out _))
|
||||
{
|
||||
|
@ -781,9 +795,64 @@ namespace BTCPayServer.Controllers
|
|||
}
|
||||
|
||||
AddErrors(result);
|
||||
model.HasPassword = await _userManager.HasPasswordAsync(user);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/invite/{userId}/{code}")]
|
||||
public async Task<IActionResult> AcceptInvite(string userId, string code)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(code))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code));
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
|
||||
var requiresSetPassword = !await _userManager.HasPasswordAsync(user);
|
||||
|
||||
if (requiresEmailConfirmation)
|
||||
{
|
||||
return await RedirectToConfirmEmail(user);
|
||||
}
|
||||
if (requiresSetPassword)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
Message = "Invitation accepted. Please set your password."
|
||||
});
|
||||
return await RedirectToSetPassword(user);
|
||||
}
|
||||
|
||||
// Inform user that a password has been set on account creation
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
||||
Message = "Your password has been set by the user who invited you."
|
||||
});
|
||||
|
||||
return RedirectToAction(nameof(Login), new { email = user.Email });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> RedirectToConfirmEmail(ApplicationUser user)
|
||||
{
|
||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
return RedirectToAction(nameof(ConfirmEmail), new { userId = user.Id, code });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> RedirectToSetPassword(ApplicationUser user)
|
||||
{
|
||||
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
return RedirectToAction(nameof(SetPassword), new { userId = user.Id, email = user.Email, code });
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddErrors(IdentityResult result)
|
||||
|
|
|
@ -9,6 +9,7 @@ using BTCPayServer.Data;
|
|||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Models.ServerViewModels;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -102,7 +103,7 @@ namespace BTCPayServer.Controllers
|
|||
bool? adminStatusChanged = null;
|
||||
bool? approvalStatusChanged = null;
|
||||
|
||||
if (user.RequiresApproval && viewModel.Approved.HasValue)
|
||||
if (user.RequiresApproval && viewModel.Approved.HasValue && user.Approved != viewModel.Approved.Value)
|
||||
{
|
||||
approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri());
|
||||
}
|
||||
|
@ -149,7 +150,6 @@ namespace BTCPayServer.Controllers
|
|||
[HttpGet("server/users/new")]
|
||||
public IActionResult CreateUser()
|
||||
{
|
||||
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
|
||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||
return View();
|
||||
}
|
||||
|
@ -157,13 +157,11 @@ namespace BTCPayServer.Controllers
|
|||
[HttpPost("server/users/new")]
|
||||
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
|
||||
{
|
||||
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
|
||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||
if (!_Options.CheatMode)
|
||||
model.IsAdmin = false;
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
IdentityResult result;
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = model.Email,
|
||||
|
@ -171,18 +169,13 @@ namespace BTCPayServer.Controllers
|
|||
EmailConfirmed = model.EmailConfirmed,
|
||||
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
|
||||
RequiresApproval = _policiesSettings.RequiresUserApproval,
|
||||
Approved = model.Approved,
|
||||
Approved = true, // auto-approve users created by an admin
|
||||
Created = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(model.Password))
|
||||
{
|
||||
result = await _UserManager.CreateAsync(user, model.Password);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = await _UserManager.CreateAsync(user);
|
||||
}
|
||||
var result = string.IsNullOrEmpty(model.Password)
|
||||
? await _UserManager.CreateAsync(user)
|
||||
: await _UserManager.CreateAsync(user, model.Password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
|
@ -190,37 +183,30 @@ namespace BTCPayServer.Controllers
|
|||
model.IsAdmin = false;
|
||||
|
||||
var tcs = new TaskCompletionSource<Uri>();
|
||||
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
|
||||
|
||||
_eventAggregator.Publish(new UserRegisteredEvent()
|
||||
_eventAggregator.Publish(new UserRegisteredEvent
|
||||
{
|
||||
Kind = UserRegisteredEventKind.Invite,
|
||||
RequestUri = Request.GetAbsoluteRootUri(),
|
||||
User = user,
|
||||
Admin = model.IsAdmin is true,
|
||||
InvitedByUser = currentUser,
|
||||
Admin = model.IsAdmin,
|
||||
CallbackUrlGenerated = tcs
|
||||
});
|
||||
|
||||
var callbackUrl = await tcs.Task;
|
||||
|
||||
if (user.RequiresEmailConfirmation && !user.EmailConfirmed)
|
||||
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||
var info = settings.IsComplete()
|
||||
? "An invitation email has been sent.<br/>You may alternatively"
|
||||
: "An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to";
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
AllowDismiss = false,
|
||||
Html =
|
||||
$"Account created without a set password. An email will be sent (if configured) to set the password.<br/> You may alternatively share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
|
||||
});
|
||||
}
|
||||
else if (!await _UserManager.HasPasswordAsync(user))
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
AllowDismiss = false,
|
||||
Html =
|
||||
$"Account created without a set password. An email will be sent (if configured) to set the password.<br/> You may alternatively share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
|
||||
});
|
||||
}
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
AllowDismiss = false,
|
||||
Html = $"Account successfully created. {info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
|
||||
});
|
||||
return RedirectToAction(nameof(ListUsers));
|
||||
}
|
||||
|
||||
|
@ -377,8 +363,5 @@ namespace BTCPayServer.Controllers
|
|||
|
||||
[Display(Name = "Email confirmed?")]
|
||||
public bool EmailConfirmed { get; set; }
|
||||
|
||||
[Display(Name = "User approved?")]
|
||||
public bool Approved { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,9 +16,7 @@ using BTCPayServer.Abstractions.Models;
|
|||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Hosting;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.ServerViewModels;
|
||||
using BTCPayServer.Models.StoreViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
|
@ -26,10 +24,8 @@ using BTCPayServer.Services;
|
|||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Storage.Models;
|
||||
using BTCPayServer.Storage.Services;
|
||||
using BTCPayServer.Storage.Services.Providers;
|
||||
using BTCPayServer.Validation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
@ -1180,7 +1176,7 @@ namespace BTCPayServer.Controllers
|
|||
return View(vm);
|
||||
}
|
||||
|
||||
[Route("server/emails")]
|
||||
[HttpGet("server/emails")]
|
||||
public async Task<IActionResult> Emails()
|
||||
{
|
||||
var email = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||
|
@ -1191,8 +1187,7 @@ namespace BTCPayServer.Controllers
|
|||
return View(vm);
|
||||
}
|
||||
|
||||
[Route("server/emails")]
|
||||
[HttpPost]
|
||||
[HttpPost("server/emails")]
|
||||
public async Task<IActionResult> Emails(ServerEmailsViewModel model, string command)
|
||||
{
|
||||
if (command == "Test")
|
||||
|
@ -1246,7 +1241,7 @@ namespace BTCPayServer.Controllers
|
|||
return View(model);
|
||||
}
|
||||
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||
if (new EmailsViewModel(oldSettings).PasswordSet)
|
||||
if (new ServerEmailsViewModel(oldSettings).PasswordSet)
|
||||
{
|
||||
model.Settings.Password = oldSettings.Password;
|
||||
}
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
using System;
|
||||
using BTCPayServer.Data;
|
||||
|
||||
namespace BTCPayServer.Events
|
||||
namespace BTCPayServer.Events;
|
||||
|
||||
public class UserApprovedEvent
|
||||
{
|
||||
public class UserApprovedEvent
|
||||
{
|
||||
public ApplicationUser User { get; set; }
|
||||
public bool Approved { get; set; }
|
||||
public Uri RequestUri { get; set; }
|
||||
}
|
||||
public ApplicationUser User { get; set; }
|
||||
public Uri RequestUri { get; set; }
|
||||
}
|
||||
|
|
10
BTCPayServer/Events/UserConfirmedEmailEvent.cs
Normal file
10
BTCPayServer/Events/UserConfirmedEmailEvent.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using System;
|
||||
using BTCPayServer.Data;
|
||||
|
||||
namespace BTCPayServer.Events;
|
||||
|
||||
public class UserConfirmedEmailEvent
|
||||
{
|
||||
public ApplicationUser User { get; set; }
|
||||
public Uri RequestUri { get; set; }
|
||||
}
|
|
@ -2,14 +2,20 @@ using System;
|
|||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
|
||||
namespace BTCPayServer.Events
|
||||
{
|
||||
public class UserRegisteredEvent
|
||||
{
|
||||
public ApplicationUser User { get; set; }
|
||||
public bool Admin { get; set; }
|
||||
public Uri RequestUri { get; set; }
|
||||
namespace BTCPayServer.Events;
|
||||
|
||||
public TaskCompletionSource<Uri> CallbackUrlGenerated;
|
||||
}
|
||||
public class UserRegisteredEvent
|
||||
{
|
||||
public ApplicationUser User { get; set; }
|
||||
public bool Admin { get; set; }
|
||||
public UserRegisteredEventKind Kind { get; set; } = UserRegisteredEventKind.Registration;
|
||||
public Uri RequestUri { get; set; }
|
||||
public ApplicationUser InvitedByUser { get; set; }
|
||||
public TaskCompletionSource<Uri> CallbackUrlGenerated;
|
||||
}
|
||||
|
||||
public enum UserRegisteredEventKind
|
||||
{
|
||||
Registration,
|
||||
Invite
|
||||
}
|
||||
|
|
|
@ -19,21 +19,32 @@ namespace BTCPayServer.Services
|
|||
|
||||
public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||
{
|
||||
emailSender.SendEmail(address, "Confirm your email",
|
||||
$"Please confirm your account by clicking this link: <a href='{HtmlEncoder.Default.Encode(link)}'>link</a>");
|
||||
emailSender.SendEmail(address, "BTCPay Server: Confirm your email",
|
||||
$"Please confirm your account by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
|
||||
}
|
||||
|
||||
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||
{
|
||||
emailSender.SendEmail(address, "Your account has been approved",
|
||||
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>");
|
||||
emailSender.SendEmail(address, "BTCPay Server: Your account has been approved",
|
||||
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>.");
|
||||
}
|
||||
|
||||
public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword)
|
||||
public static void SendResetPassword(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||
{
|
||||
var subject = $"{(newPassword ? "Set" : "Update")} Password";
|
||||
var body = $"A request has been made to {(newPassword ? "set" : "update")} your BTCPay Server password. Please confirm your password by clicking below. <br/><br/> {CallToAction(subject, HtmlEncoder.Default.Encode(link))}";
|
||||
emailSender.SendEmail(address, subject, $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
|
||||
var body = $"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", HtmlEncoder.Default.Encode(link))}";
|
||||
emailSender.SendEmail(address, "BTCPay Server: Update Password", $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
|
||||
}
|
||||
|
||||
public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||
{
|
||||
emailSender.SendEmail(address, "BTCPay Server: Invitation",
|
||||
$"Please complete your account setup by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
|
||||
}
|
||||
|
||||
public static void SendNewUserInfo(this IEmailSender emailSender, MailboxAddress address, string newUserInfo, string link)
|
||||
{
|
||||
emailSender.SendEmail(address, $"BTCPay Server: {newUserInfo}",
|
||||
$"{newUserInfo}. You can verify and approve the account here: <a href='{HtmlEncoder.Default.Encode(link)}'>User details</a>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,19 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
return null;
|
||||
}
|
||||
#nullable restore
|
||||
|
||||
public static string UserDetailsLink(this LinkGenerator urlHelper, string userId, string scheme, HostString host, string pathbase)
|
||||
{
|
||||
return urlHelper.GetUriByAction(nameof(UIServerController.User), "UIServer",
|
||||
new { userId }, scheme, host, pathbase);
|
||||
}
|
||||
|
||||
public static string InvitationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||
{
|
||||
return urlHelper.GetUriByAction(nameof(UIAccountController.AcceptInvite), "UIAccount",
|
||||
new { userId, code }, scheme, host, pathbase);
|
||||
}
|
||||
|
||||
public static string EmailConfirmationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||
{
|
||||
return urlHelper.GetUriByAction(nameof(UIAccountController.ConfirmEmail), "UIAccount",
|
||||
|
@ -33,8 +46,7 @@ namespace Microsoft.AspNetCore.Mvc
|
|||
{
|
||||
return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase);
|
||||
}
|
||||
|
||||
public static string ResetPasswordCallbackLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||
public static string ResetPasswordLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||
{
|
||||
return urlHelper.GetUriByAction(
|
||||
action: nameof(UIAccountController.SetPassword),
|
||||
|
|
|
@ -13,112 +13,129 @@ using Microsoft.AspNetCore.Identity;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
namespace BTCPayServer.HostedServices;
|
||||
|
||||
public class UserEventHostedService(
|
||||
EventAggregator eventAggregator,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
NotificationSender notificationSender,
|
||||
LinkGenerator generator,
|
||||
Logs logs)
|
||||
: EventHostedServiceBase(eventAggregator, logs)
|
||||
{
|
||||
public class UserEventHostedService : EventHostedServiceBase
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
private readonly NotificationSender _notificationSender;
|
||||
private readonly LinkGenerator _generator;
|
||||
Subscribe<UserRegisteredEvent>();
|
||||
Subscribe<UserApprovedEvent>();
|
||||
Subscribe<UserConfirmedEmailEvent>();
|
||||
Subscribe<UserPasswordResetRequestedEvent>();
|
||||
}
|
||||
|
||||
public UserEventHostedService(
|
||||
EventAggregator eventAggregator,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
NotificationSender notificationSender,
|
||||
LinkGenerator generator,
|
||||
Logs logs) : base(eventAggregator, logs)
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
string code;
|
||||
string callbackUrl;
|
||||
Uri uri;
|
||||
HostString host;
|
||||
ApplicationUser user;
|
||||
IEmailSender emailSender;
|
||||
switch (evt)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_emailSenderFactory = emailSenderFactory;
|
||||
_notificationSender = notificationSender;
|
||||
_generator = generator;
|
||||
case UserRegisteredEvent ev:
|
||||
user = ev.User;
|
||||
uri = ev.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
|
||||
// can be either a self-registration or by invite from another user
|
||||
var isInvite = ev.Kind == UserRegisteredEventKind.Invite;
|
||||
var type = ev.Admin ? "admin" : "user";
|
||||
var info = isInvite ? ev.InvitedByUser != null ? $"invited by {ev.InvitedByUser.Email}" : "invited" : "registered";
|
||||
var requiresApproval = user.RequiresApproval && !user.Approved;
|
||||
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
|
||||
|
||||
// log registration info
|
||||
var newUserInfo = $"New {type} {user.Email} {info}";
|
||||
Logs.PayServer.LogInformation(newUserInfo);
|
||||
|
||||
// send notification if the user does not require email confirmation.
|
||||
// inform admins only about qualified users and not annoy them with bot registrations.
|
||||
if (requiresApproval && !requiresEmailConfirmation)
|
||||
{
|
||||
await NotifyAdminsAboutUserRequiringApproval(user, uri, newUserInfo);
|
||||
}
|
||||
|
||||
// set callback result and send email to user
|
||||
emailSender = await emailSenderFactory.GetEmailSender();
|
||||
if (isInvite)
|
||||
{
|
||||
code = await userManager.GenerateInvitationTokenAsync(user);
|
||||
callbackUrl = generator.InvitationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
|
||||
emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl);
|
||||
}
|
||||
else if (requiresEmailConfirmation)
|
||||
{
|
||||
code = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
callbackUrl = generator.EmailConfirmationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
|
||||
emailSender.SendEmailConfirmation(user.GetMailboxAddress(), callbackUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
ev.CallbackUrlGenerated?.SetResult(null);
|
||||
}
|
||||
break;
|
||||
|
||||
case UserPasswordResetRequestedEvent pwResetEvent:
|
||||
user = pwResetEvent.User;
|
||||
uri = pwResetEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
code = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
callbackUrl = generator.ResetPasswordLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
pwResetEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
Logs.PayServer.LogInformation("User {Email} requested a password reset", user.Email);
|
||||
emailSender = await emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendResetPassword(user.GetMailboxAddress(), callbackUrl);
|
||||
break;
|
||||
|
||||
case UserApprovedEvent approvedEvent:
|
||||
user = approvedEvent.User;
|
||||
if (!user.Approved) break;
|
||||
uri = approvedEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
callbackUrl = generator.LoginLink(uri.Scheme, host, uri.PathAndQuery);
|
||||
emailSender = await emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendApprovalConfirmation(user.GetMailboxAddress(), callbackUrl);
|
||||
break;
|
||||
|
||||
case UserConfirmedEmailEvent confirmedEvent:
|
||||
user = confirmedEvent.User;
|
||||
if (!user.EmailConfirmed) break;
|
||||
uri = confirmedEvent.RequestUri;
|
||||
var confirmedUserInfo = $"User {user.Email} confirmed their email address";
|
||||
Logs.PayServer.LogInformation(confirmedUserInfo);
|
||||
if (!user.RequiresApproval || user.Approved) return;
|
||||
await NotifyAdminsAboutUserRequiringApproval(user, uri, confirmedUserInfo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, Uri uri, string newUserInfo)
|
||||
{
|
||||
if (!user.RequiresApproval || user.Approved) return;
|
||||
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
|
||||
|
||||
var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
||||
var host = new HostString(uri.Host, uri.Port);
|
||||
var approvalLink = generator.UserDetailsLink(user.Id, uri.Scheme, host, uri.PathAndQuery);
|
||||
var emailSender = await emailSenderFactory.GetEmailSender();
|
||||
foreach (var admin in admins)
|
||||
{
|
||||
Subscribe<UserRegisteredEvent>();
|
||||
Subscribe<UserApprovedEvent>();
|
||||
Subscribe<UserPasswordResetRequestedEvent>();
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
string code;
|
||||
string callbackUrl;
|
||||
Uri uri;
|
||||
HostString host;
|
||||
ApplicationUser user;
|
||||
MailboxAddress address;
|
||||
IEmailSender emailSender;
|
||||
UserPasswordResetRequestedEvent userPasswordResetRequestedEvent;
|
||||
switch (evt)
|
||||
{
|
||||
case UserRegisteredEvent userRegisteredEvent:
|
||||
user = userRegisteredEvent.User;
|
||||
Logs.PayServer.LogInformation(
|
||||
$"A new user just registered {user.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
|
||||
if (user.RequiresApproval && !user.Approved)
|
||||
{
|
||||
await _notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
|
||||
}
|
||||
if (!user.EmailConfirmed && user.RequiresEmailConfirmation)
|
||||
{
|
||||
uri = userRegisteredEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
callbackUrl = _generator.EmailConfirmationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
address = user.GetMailboxAddress();
|
||||
emailSender = await _emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendEmailConfirmation(address, callbackUrl);
|
||||
}
|
||||
else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User))
|
||||
{
|
||||
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent
|
||||
{
|
||||
CallbackUrlGenerated = userRegisteredEvent.CallbackUrlGenerated,
|
||||
User = user,
|
||||
RequestUri = userRegisteredEvent.RequestUri
|
||||
};
|
||||
goto passwordSetter;
|
||||
}
|
||||
else
|
||||
{
|
||||
userRegisteredEvent.CallbackUrlGenerated?.SetResult(null);
|
||||
}
|
||||
break;
|
||||
|
||||
case UserApprovedEvent userApprovedEvent:
|
||||
if (userApprovedEvent.Approved)
|
||||
{
|
||||
uri = userApprovedEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
address = userApprovedEvent.User.GetMailboxAddress();
|
||||
callbackUrl = _generator.LoginLink(uri.Scheme, host, uri.PathAndQuery);
|
||||
emailSender = await _emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendApprovalConfirmation(address, callbackUrl);
|
||||
}
|
||||
break;
|
||||
|
||||
case UserPasswordResetRequestedEvent userPasswordResetRequestedEvent2:
|
||||
userPasswordResetRequestedEvent = userPasswordResetRequestedEvent2;
|
||||
passwordSetter:
|
||||
uri = userPasswordResetRequestedEvent.RequestUri;
|
||||
host = new HostString(uri.Host, uri.Port);
|
||||
user = userPasswordResetRequestedEvent.User;
|
||||
code = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var newPassword = await _userManager.HasPasswordAsync(user);
|
||||
callbackUrl = _generator.ResetPasswordCallbackLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
address = user.GetMailboxAddress();
|
||||
emailSender = await _emailSenderFactory.GetEmailSender();
|
||||
emailSender.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
|
||||
break;
|
||||
}
|
||||
emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,7 +88,8 @@ namespace BTCPayServer.Hosting
|
|||
.PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir));
|
||||
services.AddIdentity<ApplicationUser, IdentityRole>()
|
||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
.AddDefaultTokenProviders()
|
||||
.AddInvitationTokenProvider();
|
||||
services.Configure<AuthenticationOptions>(opts =>
|
||||
{
|
||||
opts.DefaultAuthenticateScheme = null;
|
||||
|
|
|
@ -19,5 +19,6 @@ namespace BTCPayServer.Models.AccountViewModels
|
|||
|
||||
public string Code { get; set; }
|
||||
public bool EmailSetInternally { get; set; }
|
||||
public bool HasPassword { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
13
BTCPayServer/Security/IdentityBuilderExtension.cs
Normal file
13
BTCPayServer/Security/IdentityBuilderExtension.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using BTCPayServer.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace BTCPayServer.Security;
|
||||
|
||||
public static class IdentityBuilderExtension
|
||||
{
|
||||
public static IdentityBuilder AddInvitationTokenProvider(this IdentityBuilder builder)
|
||||
{
|
||||
var provider = typeof(InvitationTokenProvider<>).MakeGenericType(typeof(ApplicationUser));
|
||||
return builder.AddTokenProvider(InvitationTokenProviderOptions.ProviderName, provider);
|
||||
}
|
||||
}
|
26
BTCPayServer/Security/InvitationTokenProvider.cs
Normal file
26
BTCPayServer/Security/InvitationTokenProvider.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
using System;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace BTCPayServer.Security;
|
||||
|
||||
// https://andrewlock.net/implementing-custom-token-providers-for-passwordless-authentication-in-asp-net-core-identity/
|
||||
public class InvitationTokenProviderOptions : DataProtectionTokenProviderOptions
|
||||
{
|
||||
public const string ProviderName = "InvitationTokenProvider";
|
||||
|
||||
public InvitationTokenProviderOptions()
|
||||
{
|
||||
Name = ProviderName;
|
||||
TokenLifespan = TimeSpan.FromDays(7);
|
||||
}
|
||||
}
|
||||
|
||||
public class InvitationTokenProvider<TUser>(
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IOptions<InvitationTokenProviderOptions> options,
|
||||
ILogger<DataProtectorTokenProvider<TUser>> logger)
|
||||
: DataProtectorTokenProvider<TUser>(dataProtectionProvider, options, logger)
|
||||
where TUser : class;
|
|
@ -4,11 +4,9 @@ using System.Collections.Generic;
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Storage.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -22,7 +20,6 @@ namespace BTCPayServer.Services
|
|||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly StoredFileRepository _storedFileRepository;
|
||||
private readonly FileService _fileService;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
||||
private readonly ILogger<UserService> _logger;
|
||||
|
@ -32,7 +29,6 @@ namespace BTCPayServer.Services
|
|||
StoredFileRepository storedFileRepository,
|
||||
FileService fileService,
|
||||
EventAggregator eventAggregator,
|
||||
StoreRepository storeRepository,
|
||||
ApplicationDbContextFactory applicationDbContextFactory,
|
||||
ILogger<UserService> logger)
|
||||
{
|
||||
|
@ -40,7 +36,6 @@ namespace BTCPayServer.Services
|
|||
_storedFileRepository = storedFileRepository;
|
||||
_fileService = fileService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_storeRepository = storeRepository;
|
||||
_applicationDbContextFactory = applicationDbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
@ -124,12 +119,12 @@ namespace BTCPayServer.Services
|
|||
var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true };
|
||||
if (succeeded)
|
||||
{
|
||||
_logger.LogInformation("User {UserId} is now {Status}", user.Id, approved ? "approved" : "unapproved");
|
||||
_eventAggregator.Publish(new UserApprovedEvent { User = user, Approved = approved, RequestUri = requestUri });
|
||||
_logger.LogInformation("User {Email} is now {Status}", user.Email, approved ? "approved" : "unapproved");
|
||||
_eventAggregator.Publish(new UserApprovedEvent { User = user, RequestUri = requestUri });
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Failed to {Action} user {UserId}", approved ? "approve" : "unapprove", user.Id);
|
||||
_logger.LogError("Failed to {Action} user {Email}", approved ? "approve" : "unapprove", user.Email);
|
||||
}
|
||||
|
||||
return succeeded;
|
||||
|
@ -152,11 +147,11 @@ namespace BTCPayServer.Services
|
|||
var res = await userManager.SetLockoutEndDateAsync(user, lockedOutDeadline);
|
||||
if (res.Succeeded)
|
||||
{
|
||||
_logger.LogInformation($"User {user.Id} is now {(lockedOutDeadline is null ? "unlocked" : "locked")}");
|
||||
_logger.LogInformation("User {Email} is now {Status}", user.Email, (lockedOutDeadline is null ? "unlocked" : "locked"));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Failed to set lockout for user {user.Id}");
|
||||
_logger.LogError("Failed to set lockout for user {Email}", user.Email);
|
||||
}
|
||||
|
||||
return res.Succeeded;
|
||||
|
@ -195,11 +190,11 @@ namespace BTCPayServer.Services
|
|||
|
||||
if (res.Succeeded)
|
||||
{
|
||||
_logger.LogInformation($"Successfully set admin status for user {user.Id}");
|
||||
_logger.LogInformation("Successfully set admin status for user {Email}", user.Email);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Error setting admin status for user {user.Id}");
|
||||
_logger.LogError("Error setting admin status for user {Email}", user.Email);
|
||||
}
|
||||
|
||||
return res.Succeeded;
|
||||
|
@ -224,11 +219,11 @@ namespace BTCPayServer.Services
|
|||
var res = await userManager.DeleteAsync(user);
|
||||
if (res.Succeeded)
|
||||
{
|
||||
_logger.LogInformation($"User {user.Id} was successfully deleted");
|
||||
_logger.LogInformation("User {Email} was successfully deleted", user.Email);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Failed to delete user {user.Id}");
|
||||
_logger.LogError("Failed to delete user {Email}", user.Email);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,19 +1,34 @@
|
|||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Security;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
public static class UserManagerExtensions
|
||||
{
|
||||
public async static Task<TUser?> FindByIdOrEmail<TUser>(this UserManager<TUser> userManager, string? idOrEmail) where TUser : class
|
||||
private const string InvitationPurpose = "invitation";
|
||||
|
||||
public static async Task<TUser?> FindByIdOrEmail<TUser>(this UserManager<TUser> userManager, string? idOrEmail) where TUser : class
|
||||
{
|
||||
if (string.IsNullOrEmpty(idOrEmail))
|
||||
return null;
|
||||
if (idOrEmail.Contains('@'))
|
||||
return await userManager.FindByEmailAsync(idOrEmail);
|
||||
else
|
||||
return await userManager.FindByIdAsync(idOrEmail);
|
||||
|
||||
return await userManager.FindByIdAsync(idOrEmail);
|
||||
}
|
||||
|
||||
public static async Task<string> GenerateInvitationTokenAsync<TUser>(this UserManager<TUser> userManager, TUser user) where TUser : class
|
||||
{
|
||||
return await userManager.GenerateUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose);
|
||||
}
|
||||
|
||||
public static async Task<TUser?> FindByInvitationTokenAsync<TUser>(this UserManager<TUser> 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);
|
||||
return isValid ? user : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,10 @@
|
|||
|
||||
@section PageHeadContent {
|
||||
<style>
|
||||
.alert {
|
||||
max-width: 35em;
|
||||
margin: var(--btcpay-space-l) auto;
|
||||
}
|
||||
.account-form {
|
||||
max-width: 35em;
|
||||
margin: 0 auto var(--btcpay-space-xl);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Reset password";
|
||||
ViewData["Title"] = $"{(Model.HasPassword ? "Reset" : "Set")} your password";
|
||||
Layout = "_LayoutSignedOut";
|
||||
}
|
||||
|
||||
|
|
|
@ -32,8 +32,6 @@
|
|||
</form>
|
||||
}
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<form method="post" asp-action="AuthorizeAPIKey">
|
||||
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
|
||||
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
|
||||
|
|
|
@ -12,8 +12,6 @@
|
|||
</script>
|
||||
}
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<form method="post" asp-controller="UIManage" asp-action="AuthorizeAPIKey">
|
||||
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
|
||||
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>
|
||||
|
|
|
@ -44,14 +44,6 @@
|
|||
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
@if (ViewData["AllowRequestApproval"] is true)
|
||||
{
|
||||
<div class="form-group form-check">
|
||||
<input asp-for="Approved" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="Approved" class="form-check-label"></label>
|
||||
<span asp-validation-for="Approved" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
|
||||
</form>
|
||||
|
|
|
@ -83,11 +83,11 @@
|
|||
@if (user is { EmailConfirmed: false, Disabled: false }) {
|
||||
<a asp-action="SendVerificationEmail" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Send verification email" data-description="This will send a verification email to <strong>@Html.Encode(user.Email)</strong>." data-confirm="Send">Resend email</a>
|
||||
}
|
||||
else if (user is { Approved: false, Disabled: false })
|
||||
@if (user is { Approved: false, Disabled: false })
|
||||
{
|
||||
<a asp-action="ApproveUser" asp-route-userId="@user.Id" asp-route-approved="true" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Approve user" data-description="This will approve the user <strong>@Html.Encode(user.Email)</strong>." data-confirm="Approve">Approve</a>
|
||||
}
|
||||
else
|
||||
@if (status.Item2 != "warning")
|
||||
{
|
||||
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled">@(user.Disabled ? "Enable" : "Disable")</a>
|
||||
}
|
||||
|
|
|
@ -46,7 +46,6 @@
|
|||
<p class="lead text-secondary">Create a store to begin accepting payments.</p>
|
||||
|
||||
<div class="text-start">
|
||||
<partial name="_StatusMessage"/>
|
||||
<partial name="_CreateStoreForm" model="Model" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue