mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-10 09:19:24 +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();
|
s.GoToHome();
|
||||||
|
|
||||||
//Change Password & Log Out
|
//Change Password & Log Out
|
||||||
|
var newPassword = "abc???";
|
||||||
s.GoToProfile(ManageNavPages.ChangePassword);
|
s.GoToProfile(ManageNavPages.ChangePassword);
|
||||||
s.Driver.FindElement(By.Id("OldPassword")).SendKeys("123456");
|
s.Driver.FindElement(By.Id("OldPassword")).SendKeys("123456");
|
||||||
s.Driver.FindElement(By.Id("NewPassword")).SendKeys("abc???");
|
s.Driver.FindElement(By.Id("NewPassword")).SendKeys(newPassword);
|
||||||
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("abc???");
|
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys(newPassword);
|
||||||
s.Driver.FindElement(By.Id("UpdatePassword")).Click();
|
s.Driver.FindElement(By.Id("UpdatePassword")).Click();
|
||||||
s.Logout();
|
s.Logout();
|
||||||
s.Driver.AssertNoError();
|
s.Driver.AssertNoError();
|
||||||
|
|
||||||
//Log In With New Password
|
//Log In With New Password
|
||||||
s.Driver.FindElement(By.Id("Email")).SendKeys(email);
|
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.Driver.FindElement(By.Id("LoginButton")).Click();
|
||||||
|
|
||||||
s.GoToHome();
|
s.GoToHome();
|
||||||
|
@ -383,11 +384,14 @@ namespace BTCPayServer.Tests
|
||||||
s.Driver.Navigate().GoToUrl(url);
|
s.Driver.Navigate().GoToUrl(url);
|
||||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
|
Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
|
||||||
Assert.Equal(usr, s.Driver.FindElement(By.Id("Email")).GetAttribute("value"));
|
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("Password")).SendKeys("123456");
|
||||||
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
|
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
|
||||||
s.Driver.FindElement(By.Id("SetPassword")).Click();
|
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("Email")).SendKeys(usr);
|
||||||
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
||||||
s.Driver.FindElement(By.Id("LoginButton")).Click();
|
s.Driver.FindElement(By.Id("LoginButton")).Click();
|
||||||
|
@ -428,11 +432,6 @@ namespace BTCPayServer.Tests
|
||||||
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
|
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
|
||||||
Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
|
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
|
// Ensure there is no unread notification yet
|
||||||
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
|
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
|
||||||
s.Logout();
|
s.Logout();
|
||||||
|
|
|
@ -185,7 +185,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
|
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
|
||||||
RequiresApproval = policies.RequiresUserApproval,
|
RequiresApproval = policies.RequiresUserApproval,
|
||||||
Created = DateTimeOffset.UtcNow,
|
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);
|
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
|
||||||
if (!passwordValidation.Succeeded)
|
if (!passwordValidation.Succeeded)
|
||||||
|
@ -214,7 +214,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
return this.CreateValidationError(ModelState);
|
return this.CreateValidationError(ModelState);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.IsAdministrator is true)
|
var isNewAdmin = request.IsAdministrator is true;
|
||||||
|
if (isNewAdmin)
|
||||||
{
|
{
|
||||||
if (!anyAdmin)
|
if (!anyAdmin)
|
||||||
{
|
{
|
||||||
|
@ -233,7 +234,21 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration, Logs);
|
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);
|
var model = await FromModel(user);
|
||||||
return CreatedAtAction(string.Empty, model);
|
return CreatedAtAction(string.Empty, model);
|
||||||
}
|
}
|
||||||
|
|
|
@ -600,7 +600,6 @@ namespace BTCPayServer.Controllers
|
||||||
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
|
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
|
||||||
settings.FirstRun = false;
|
settings.FirstRun = false;
|
||||||
await _SettingsRepository.UpdateSetting(settings);
|
await _SettingsRepository.UpdateSetting(settings);
|
||||||
|
|
||||||
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
|
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
|
||||||
RegisteredAdmin = true;
|
RegisteredAdmin = true;
|
||||||
}
|
}
|
||||||
|
@ -614,15 +613,17 @@ namespace BTCPayServer.Controllers
|
||||||
RegisteredUserId = user.Id;
|
RegisteredUserId = user.Id;
|
||||||
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = "Account created.";
|
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.";
|
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.";
|
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));
|
return RedirectToAction(nameof(Login));
|
||||||
}
|
}
|
||||||
|
@ -670,25 +671,31 @@ namespace BTCPayServer.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _userManager.ConfirmEmailAsync(user, code);
|
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
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
{
|
{
|
||||||
Severity = StatusMessageModel.StatusSeverity.Info,
|
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) });
|
return await RedirectToSetPassword(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 View("Error");
|
return View("Error");
|
||||||
|
@ -743,14 +750,20 @@ namespace BTCPayServer.Controllers
|
||||||
throw new ApplicationException("A code must be supplied for password reset.");
|
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))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByIdAsync(userId);
|
|
||||||
email = user?.Email;
|
email = user?.Email;
|
||||||
}
|
}
|
||||||
|
|
||||||
var model = new SetPasswordViewModel { Code = code, Email = email, EmailSetInternally = !string.IsNullOrEmpty(email) };
|
return View(new SetPasswordViewModel
|
||||||
return View(model);
|
{
|
||||||
|
Code = code,
|
||||||
|
Email = email,
|
||||||
|
EmailSetInternally = !string.IsNullOrEmpty(email),
|
||||||
|
HasPassword = hasPassword
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/login/set-password")]
|
[HttpPost("/login/set-password")]
|
||||||
|
@ -762,6 +775,7 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(model.Email);
|
var user = await _userManager.FindByEmailAsync(model.Email);
|
||||||
if (!UserService.TryCanLogin(user, out _))
|
if (!UserService.TryCanLogin(user, out _))
|
||||||
{
|
{
|
||||||
|
@ -781,9 +795,64 @@ namespace BTCPayServer.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
AddErrors(result);
|
AddErrors(result);
|
||||||
|
model.HasPassword = await _userManager.HasPasswordAsync(user);
|
||||||
return View(model);
|
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
|
#region Helpers
|
||||||
|
|
||||||
private void AddErrors(IdentityResult result)
|
private void AddErrors(IdentityResult result)
|
||||||
|
|
|
@ -9,6 +9,7 @@ using BTCPayServer.Data;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.Models.ServerViewModels;
|
using BTCPayServer.Models.ServerViewModels;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -102,7 +103,7 @@ namespace BTCPayServer.Controllers
|
||||||
bool? adminStatusChanged = null;
|
bool? adminStatusChanged = null;
|
||||||
bool? approvalStatusChanged = 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());
|
approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri());
|
||||||
}
|
}
|
||||||
|
@ -149,7 +150,6 @@ namespace BTCPayServer.Controllers
|
||||||
[HttpGet("server/users/new")]
|
[HttpGet("server/users/new")]
|
||||||
public IActionResult CreateUser()
|
public IActionResult CreateUser()
|
||||||
{
|
{
|
||||||
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
|
|
||||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
@ -157,13 +157,11 @@ namespace BTCPayServer.Controllers
|
||||||
[HttpPost("server/users/new")]
|
[HttpPost("server/users/new")]
|
||||||
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
|
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
|
||||||
{
|
{
|
||||||
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
|
|
||||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||||
if (!_Options.CheatMode)
|
if (!_Options.CheatMode)
|
||||||
model.IsAdmin = false;
|
model.IsAdmin = false;
|
||||||
if (ModelState.IsValid)
|
if (ModelState.IsValid)
|
||||||
{
|
{
|
||||||
IdentityResult result;
|
|
||||||
var user = new ApplicationUser
|
var user = new ApplicationUser
|
||||||
{
|
{
|
||||||
UserName = model.Email,
|
UserName = model.Email,
|
||||||
|
@ -171,18 +169,13 @@ namespace BTCPayServer.Controllers
|
||||||
EmailConfirmed = model.EmailConfirmed,
|
EmailConfirmed = model.EmailConfirmed,
|
||||||
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
|
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
|
||||||
RequiresApproval = _policiesSettings.RequiresUserApproval,
|
RequiresApproval = _policiesSettings.RequiresUserApproval,
|
||||||
Approved = model.Approved,
|
Approved = true, // auto-approve users created by an admin
|
||||||
Created = DateTimeOffset.UtcNow
|
Created = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(model.Password))
|
var result = string.IsNullOrEmpty(model.Password)
|
||||||
{
|
? await _UserManager.CreateAsync(user)
|
||||||
result = await _UserManager.CreateAsync(user, model.Password);
|
: await _UserManager.CreateAsync(user, model.Password);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result = await _UserManager.CreateAsync(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
|
@ -190,37 +183,30 @@ namespace BTCPayServer.Controllers
|
||||||
model.IsAdmin = false;
|
model.IsAdmin = false;
|
||||||
|
|
||||||
var tcs = new TaskCompletionSource<Uri>();
|
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(),
|
RequestUri = Request.GetAbsoluteRootUri(),
|
||||||
User = user,
|
User = user,
|
||||||
Admin = model.IsAdmin is true,
|
InvitedByUser = currentUser,
|
||||||
|
Admin = model.IsAdmin,
|
||||||
CallbackUrlGenerated = tcs
|
CallbackUrlGenerated = tcs
|
||||||
});
|
});
|
||||||
|
|
||||||
var callbackUrl = await tcs.Task;
|
var callbackUrl = await tcs.Task;
|
||||||
|
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";
|
||||||
|
|
||||||
if (user.RequiresEmailConfirmation && !user.EmailConfirmed)
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
{
|
{
|
||||||
|
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
AllowDismiss = false,
|
||||||
{
|
Html = $"Account successfully created. {info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
|
||||||
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>"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return RedirectToAction(nameof(ListUsers));
|
return RedirectToAction(nameof(ListUsers));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -377,8 +363,5 @@ namespace BTCPayServer.Controllers
|
||||||
|
|
||||||
[Display(Name = "Email confirmed?")]
|
[Display(Name = "Email confirmed?")]
|
||||||
public bool EmailConfirmed { get; set; }
|
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.Configuration;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.HostedServices;
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.Hosting;
|
|
||||||
using BTCPayServer.Logging;
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Models;
|
|
||||||
using BTCPayServer.Models.ServerViewModels;
|
using BTCPayServer.Models.ServerViewModels;
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
|
@ -26,10 +24,8 @@ using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using BTCPayServer.Storage.Models;
|
|
||||||
using BTCPayServer.Storage.Services;
|
using BTCPayServer.Storage.Services;
|
||||||
using BTCPayServer.Storage.Services.Providers;
|
using BTCPayServer.Storage.Services.Providers;
|
||||||
using BTCPayServer.Validation;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
@ -1180,7 +1176,7 @@ namespace BTCPayServer.Controllers
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("server/emails")]
|
[HttpGet("server/emails")]
|
||||||
public async Task<IActionResult> Emails()
|
public async Task<IActionResult> Emails()
|
||||||
{
|
{
|
||||||
var email = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
var email = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||||
|
@ -1191,8 +1187,7 @@ namespace BTCPayServer.Controllers
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("server/emails")]
|
[HttpPost("server/emails")]
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> Emails(ServerEmailsViewModel model, string command)
|
public async Task<IActionResult> Emails(ServerEmailsViewModel model, string command)
|
||||||
{
|
{
|
||||||
if (command == "Test")
|
if (command == "Test")
|
||||||
|
@ -1246,7 +1241,7 @@ namespace BTCPayServer.Controllers
|
||||||
return View(model);
|
return View(model);
|
||||||
}
|
}
|
||||||
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||||
if (new EmailsViewModel(oldSettings).PasswordSet)
|
if (new ServerEmailsViewModel(oldSettings).PasswordSet)
|
||||||
{
|
{
|
||||||
model.Settings.Password = oldSettings.Password;
|
model.Settings.Password = oldSettings.Password;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
using System;
|
using System;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
|
||||||
namespace BTCPayServer.Events
|
namespace BTCPayServer.Events;
|
||||||
|
|
||||||
|
public class UserApprovedEvent
|
||||||
{
|
{
|
||||||
public class UserApprovedEvent
|
public ApplicationUser User { get; set; }
|
||||||
{
|
public Uri RequestUri { get; set; }
|
||||||
public ApplicationUser User { get; set; }
|
|
||||||
public bool Approved { 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 System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
|
||||||
namespace BTCPayServer.Events
|
namespace BTCPayServer.Events;
|
||||||
{
|
|
||||||
public class UserRegisteredEvent
|
|
||||||
{
|
|
||||||
public ApplicationUser User { get; set; }
|
|
||||||
public bool Admin { get; set; }
|
|
||||||
public Uri RequestUri { get; set; }
|
|
||||||
|
|
||||||
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)
|
public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||||
{
|
{
|
||||||
emailSender.SendEmail(address, "Confirm your email",
|
emailSender.SendEmail(address, "BTCPay Server: Confirm your email",
|
||||||
$"Please confirm your account by clicking this link: <a href='{HtmlEncoder.Default.Encode(link)}'>link</a>");
|
$"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)
|
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||||
{
|
{
|
||||||
emailSender.SendEmail(address, "Your account has been approved",
|
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>");
|
$"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 reset your BTCPay Server password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", HtmlEncoder.Default.Encode(link))}";
|
||||||
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, "BTCPay Server: Update Password", $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
|
||||||
emailSender.SendEmail(address, subject, $"<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;
|
return null;
|
||||||
}
|
}
|
||||||
#nullable restore
|
#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)
|
public static string EmailConfirmationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||||
{
|
{
|
||||||
return urlHelper.GetUriByAction(nameof(UIAccountController.ConfirmEmail), "UIAccount",
|
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);
|
return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase);
|
||||||
}
|
}
|
||||||
|
public static string ResetPasswordLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||||
public static string ResetPasswordCallbackLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
|
||||||
{
|
{
|
||||||
return urlHelper.GetUriByAction(
|
return urlHelper.GetUriByAction(
|
||||||
action: nameof(UIAccountController.SetPassword),
|
action: nameof(UIAccountController.SetPassword),
|
||||||
|
|
|
@ -13,112 +13,129 @@ using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.Logging;
|
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;
|
Subscribe<UserRegisteredEvent>();
|
||||||
private readonly EmailSenderFactory _emailSenderFactory;
|
Subscribe<UserApprovedEvent>();
|
||||||
private readonly NotificationSender _notificationSender;
|
Subscribe<UserConfirmedEmailEvent>();
|
||||||
private readonly LinkGenerator _generator;
|
Subscribe<UserPasswordResetRequestedEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
public UserEventHostedService(
|
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||||
EventAggregator eventAggregator,
|
{
|
||||||
UserManager<ApplicationUser> userManager,
|
string code;
|
||||||
EmailSenderFactory emailSenderFactory,
|
string callbackUrl;
|
||||||
NotificationSender notificationSender,
|
Uri uri;
|
||||||
LinkGenerator generator,
|
HostString host;
|
||||||
Logs logs) : base(eventAggregator, logs)
|
ApplicationUser user;
|
||||||
|
IEmailSender emailSender;
|
||||||
|
switch (evt)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
case UserRegisteredEvent ev:
|
||||||
_emailSenderFactory = emailSenderFactory;
|
user = ev.User;
|
||||||
_notificationSender = notificationSender;
|
uri = ev.RequestUri;
|
||||||
_generator = generator;
|
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>();
|
emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,7 +88,8 @@ namespace BTCPayServer.Hosting
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir));
|
.PersistKeysToFileSystem(new DirectoryInfo(new DataDirectories().Configure(Configuration).DataDir));
|
||||||
services.AddIdentity<ApplicationUser, IdentityRole>()
|
services.AddIdentity<ApplicationUser, IdentityRole>()
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders()
|
||||||
|
.AddInvitationTokenProvider();
|
||||||
services.Configure<AuthenticationOptions>(opts =>
|
services.Configure<AuthenticationOptions>(opts =>
|
||||||
{
|
{
|
||||||
opts.DefaultAuthenticateScheme = null;
|
opts.DefaultAuthenticateScheme = null;
|
||||||
|
|
|
@ -19,5 +19,6 @@ namespace BTCPayServer.Models.AccountViewModels
|
||||||
|
|
||||||
public string Code { get; set; }
|
public string Code { get; set; }
|
||||||
public bool EmailSetInternally { 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.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer;
|
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.Services.Stores;
|
|
||||||
using BTCPayServer.Storage.Services;
|
using BTCPayServer.Storage.Services;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -22,7 +20,6 @@ namespace BTCPayServer.Services
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly StoredFileRepository _storedFileRepository;
|
private readonly StoredFileRepository _storedFileRepository;
|
||||||
private readonly FileService _fileService;
|
private readonly FileService _fileService;
|
||||||
private readonly StoreRepository _storeRepository;
|
|
||||||
private readonly EventAggregator _eventAggregator;
|
private readonly EventAggregator _eventAggregator;
|
||||||
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
|
||||||
private readonly ILogger<UserService> _logger;
|
private readonly ILogger<UserService> _logger;
|
||||||
|
@ -32,7 +29,6 @@ namespace BTCPayServer.Services
|
||||||
StoredFileRepository storedFileRepository,
|
StoredFileRepository storedFileRepository,
|
||||||
FileService fileService,
|
FileService fileService,
|
||||||
EventAggregator eventAggregator,
|
EventAggregator eventAggregator,
|
||||||
StoreRepository storeRepository,
|
|
||||||
ApplicationDbContextFactory applicationDbContextFactory,
|
ApplicationDbContextFactory applicationDbContextFactory,
|
||||||
ILogger<UserService> logger)
|
ILogger<UserService> logger)
|
||||||
{
|
{
|
||||||
|
@ -40,7 +36,6 @@ namespace BTCPayServer.Services
|
||||||
_storedFileRepository = storedFileRepository;
|
_storedFileRepository = storedFileRepository;
|
||||||
_fileService = fileService;
|
_fileService = fileService;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
_storeRepository = storeRepository;
|
|
||||||
_applicationDbContextFactory = applicationDbContextFactory;
|
_applicationDbContextFactory = applicationDbContextFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
@ -124,12 +119,12 @@ namespace BTCPayServer.Services
|
||||||
var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true };
|
var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true };
|
||||||
if (succeeded)
|
if (succeeded)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("User {UserId} is now {Status}", user.Id, approved ? "approved" : "unapproved");
|
_logger.LogInformation("User {Email} is now {Status}", user.Email, approved ? "approved" : "unapproved");
|
||||||
_eventAggregator.Publish(new UserApprovedEvent { User = user, Approved = approved, RequestUri = requestUri });
|
_eventAggregator.Publish(new UserApprovedEvent { User = user, RequestUri = requestUri });
|
||||||
}
|
}
|
||||||
else
|
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;
|
return succeeded;
|
||||||
|
@ -152,11 +147,11 @@ namespace BTCPayServer.Services
|
||||||
var res = await userManager.SetLockoutEndDateAsync(user, lockedOutDeadline);
|
var res = await userManager.SetLockoutEndDateAsync(user, lockedOutDeadline);
|
||||||
if (res.Succeeded)
|
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
|
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;
|
return res.Succeeded;
|
||||||
|
@ -195,11 +190,11 @@ namespace BTCPayServer.Services
|
||||||
|
|
||||||
if (res.Succeeded)
|
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
|
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;
|
return res.Succeeded;
|
||||||
|
@ -224,11 +219,11 @@ namespace BTCPayServer.Services
|
||||||
var res = await userManager.DeleteAsync(user);
|
var res = await userManager.DeleteAsync(user);
|
||||||
if (res.Succeeded)
|
if (res.Succeeded)
|
||||||
{
|
{
|
||||||
_logger.LogInformation($"User {user.Id} was successfully deleted");
|
_logger.LogInformation("User {Email} was successfully deleted", user.Email);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogError($"Failed to delete user {user.Id}");
|
_logger.LogError("Failed to delete user {Email}", user.Email);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,19 +1,34 @@
|
||||||
#nullable enable
|
#nullable enable
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Security;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
namespace BTCPayServer
|
namespace BTCPayServer
|
||||||
{
|
{
|
||||||
public static class UserManagerExtensions
|
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))
|
if (string.IsNullOrEmpty(idOrEmail))
|
||||||
return null;
|
return null;
|
||||||
if (idOrEmail.Contains('@'))
|
if (idOrEmail.Contains('@'))
|
||||||
return await userManager.FindByEmailAsync(idOrEmail);
|
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 {
|
@section PageHeadContent {
|
||||||
<style>
|
<style>
|
||||||
|
.alert {
|
||||||
|
max-width: 35em;
|
||||||
|
margin: var(--btcpay-space-l) auto;
|
||||||
|
}
|
||||||
.account-form {
|
.account-form {
|
||||||
max-width: 35em;
|
max-width: 35em;
|
||||||
margin: 0 auto var(--btcpay-space-xl);
|
margin: 0 auto var(--btcpay-space-xl);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
|
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Reset password";
|
ViewData["Title"] = $"{(Model.HasPassword ? "Reset" : "Set")} your password";
|
||||||
Layout = "_LayoutSignedOut";
|
Layout = "_LayoutSignedOut";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,8 +32,6 @@
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
<partial name="_StatusMessage" />
|
|
||||||
|
|
||||||
<form method="post" asp-action="AuthorizeAPIKey">
|
<form method="post" asp-action="AuthorizeAPIKey">
|
||||||
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
|
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
|
||||||
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
|
<input type="hidden" asp-for="Permissions" value="@Model.Permissions"/>
|
||||||
|
|
|
@ -12,8 +12,6 @@
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
<partial name="_StatusMessage" />
|
|
||||||
|
|
||||||
<form method="post" asp-controller="UIManage" asp-action="AuthorizeAPIKey">
|
<form method="post" asp-controller="UIManage" asp-action="AuthorizeAPIKey">
|
||||||
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
|
<input type="hidden" asp-for="ApplicationName" value="@Model.ApplicationName"/>
|
||||||
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>
|
<input type="hidden" asp-for="ApplicationIdentifier" value="@Model.ApplicationIdentifier"/>
|
||||||
|
|
|
@ -44,14 +44,6 @@
|
||||||
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
|
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
|
||||||
</div>
|
</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>
|
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -83,11 +83,11 @@
|
||||||
@if (user is { EmailConfirmed: false, Disabled: false }) {
|
@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>
|
<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>
|
<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>
|
<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>
|
<p class="lead text-secondary">Create a store to begin accepting payments.</p>
|
||||||
|
|
||||||
<div class="text-start">
|
<div class="text-start">
|
||||||
<partial name="_StatusMessage"/>
|
|
||||||
<partial name="_CreateStoreForm" model="Model" />
|
<partial name="_CreateStoreForm" model="Model" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Add table
Reference in a new issue