mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-01-18 21:32:27 +01:00
Invitation process improvements (#6188)
* Server: Make sending email optional when adding user Closes #6158. * Generate custom invite token and store it in user blob Closes btcpayserver/app/#46. * QR code for user invite Closes #6157. * Text fix
This commit is contained in:
parent
3342122be2
commit
f3d485da53
@ -46,5 +46,6 @@ namespace BTCPayServer.Data
|
||||
public bool ShowInvoiceStatusChangeHint { get; set; }
|
||||
public string ImageUrl { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string InvitationToken { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -371,7 +371,7 @@ namespace BTCPayServer.Tests
|
||||
var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com";
|
||||
s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
|
||||
s.ClickPagePrimary();
|
||||
var url = s.FindAlertMessage().FindElement(By.TagName("a")).Text;
|
||||
var url = s.Driver.FindElement(By.Id("InvitationUrl")).GetAttribute("data-text");
|
||||
|
||||
s.Logout();
|
||||
s.Driver.Navigate().GoToUrl(url);
|
||||
|
@ -812,7 +812,7 @@ namespace BTCPayServer.Controllers
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code));
|
||||
var user = await _userManager.FindByInvitationTokenAsync<ApplicationUser>(userId, Uri.UnescapeDataString(code));
|
||||
if (user == null)
|
||||
{
|
||||
return NotFound();
|
||||
@ -827,6 +827,9 @@ namespace BTCPayServer.Controllers
|
||||
RequestUri = Request.GetAbsoluteRootUri()
|
||||
});
|
||||
|
||||
// unset used token
|
||||
await _userManager.UnsetInvitationTokenAsync<ApplicationUser>(user.Id);
|
||||
|
||||
if (requiresEmailConfirmation)
|
||||
{
|
||||
return await RedirectToConfirmEmail(user);
|
||||
|
@ -58,18 +58,28 @@ namespace BTCPayServer.Controllers
|
||||
.Skip(model.Skip)
|
||||
.Take(model.Count)
|
||||
.ToListAsync())
|
||||
.Select(u => new UsersViewModel.UserViewModel
|
||||
.Select(u =>
|
||||
{
|
||||
Name = u.GetBlob()?.Name,
|
||||
ImageUrl = u.GetBlob()?.ImageUrl,
|
||||
Email = u.Email,
|
||||
Id = u.Id,
|
||||
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
|
||||
Approved = u.RequiresApproval ? u.Approved : null,
|
||||
Created = u.Created,
|
||||
Roles = u.UserRoles.Select(role => role.RoleId),
|
||||
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
|
||||
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
|
||||
var blob = u.GetBlob();
|
||||
return new UsersViewModel.UserViewModel
|
||||
{
|
||||
Name = blob?.Name,
|
||||
ImageUrl = blob?.ImageUrl,
|
||||
Email = u.Email,
|
||||
Id = u.Id,
|
||||
InvitationUrl =
|
||||
string.IsNullOrEmpty(blob?.InvitationToken)
|
||||
? null
|
||||
: _linkGenerator.InvitationLink(u.Id, blob.InvitationToken, Request.Scheme,
|
||||
Request.Host, Request.PathBase),
|
||||
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
|
||||
Approved = u.RequiresApproval ? u.Approved : null,
|
||||
Created = u.Created,
|
||||
Roles = u.UserRoles.Select(role => role.RoleId),
|
||||
Disabled = u.LockoutEnabled && u.LockoutEnd != null &&
|
||||
DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
|
||||
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
return View(model);
|
||||
@ -88,6 +98,7 @@ namespace BTCPayServer.Controllers
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
Name = blob?.Name,
|
||||
InvitationUrl = string.IsNullOrEmpty(blob?.InvitationToken) ? null : _linkGenerator.InvitationLink(user.Id, blob.InvitationToken, Request.Scheme, Request.Host, Request.PathBase),
|
||||
ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
|
||||
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
|
||||
Approved = user.RequiresApproval ? user.Approved : null,
|
||||
@ -200,16 +211,20 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
[HttpGet("server/users/new")]
|
||||
public IActionResult CreateUser()
|
||||
public async Task<IActionResult> CreateUser()
|
||||
{
|
||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||
return View();
|
||||
await PrepareCreateUserViewData();
|
||||
var vm = new RegisterFromAdminViewModel
|
||||
{
|
||||
SendInvitationEmail = ViewData["CanSendEmail"] is true
|
||||
};
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
[HttpPost("server/users/new")]
|
||||
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
|
||||
{
|
||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||
await PrepareCreateUserViewData();
|
||||
if (!_Options.CheatMode)
|
||||
model.IsAdmin = false;
|
||||
if (ModelState.IsValid)
|
||||
@ -236,6 +251,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
var tcs = new TaskCompletionSource<Uri>();
|
||||
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
|
||||
var sendEmail = model.SendInvitationEmail && ViewData["CanSendEmail"] is true;
|
||||
|
||||
_eventAggregator.Publish(new UserRegisteredEvent
|
||||
{
|
||||
@ -243,23 +259,23 @@ namespace BTCPayServer.Controllers
|
||||
Kind = UserRegisteredEventKind.Invite,
|
||||
User = user,
|
||||
InvitedByUser = currentUser,
|
||||
SendInvitationEmail = sendEmail,
|
||||
Admin = model.IsAdmin,
|
||||
CallbackUrlGenerated = tcs
|
||||
});
|
||||
|
||||
var callbackUrl = await tcs.Task;
|
||||
var settings = await _SettingsRepository.GetSettingAsync<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";
|
||||
var info = sendEmail
|
||||
? "An invitation email has been sent. You may alternatively"
|
||||
: "An invitation email has not been sent. You need to";
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
AllowDismiss = false,
|
||||
Html = $"Account successfully created. {info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
|
||||
Html = $"Account successfully created. {info} share this link with them:<br/>{callbackUrl}"
|
||||
});
|
||||
return RedirectToAction(nameof(ListUsers));
|
||||
return RedirectToAction(nameof(User), new { userId = user.Id });
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
@ -391,6 +407,13 @@ namespace BTCPayServer.Controllers
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent";
|
||||
return RedirectToAction(nameof(ListUsers));
|
||||
}
|
||||
|
||||
private async Task PrepareCreateUserViewData()
|
||||
{
|
||||
var emailSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||
ViewData["CanSendEmail"] = emailSettings.IsComplete();
|
||||
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
|
||||
}
|
||||
}
|
||||
|
||||
public class RegisterFromAdminViewModel
|
||||
@ -415,5 +438,8 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[Display(Name = "Email confirmed?")]
|
||||
public bool EmailConfirmed { get; set; }
|
||||
|
||||
[Display(Name = "Send invitation email")]
|
||||
public bool SendInvitationEmail { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ public class UserRegisteredEvent
|
||||
public UserRegisteredEventKind Kind { get; set; } = UserRegisteredEventKind.Registration;
|
||||
public Uri RequestUri { get; set; }
|
||||
public ApplicationUser InvitedByUser { get; set; }
|
||||
public bool SendInvitationEmail { get; set; }
|
||||
public TaskCompletionSource<Uri> CallbackUrlGenerated;
|
||||
}
|
||||
|
||||
|
@ -73,11 +73,12 @@ public class UserEventHostedService(
|
||||
emailSender = await emailSenderFactory.GetEmailSender();
|
||||
if (isInvite)
|
||||
{
|
||||
code = await userManager.GenerateInvitationTokenAsync(user);
|
||||
code = await userManager.GenerateInvitationTokenAsync<ApplicationUser>(user.Id);
|
||||
callbackUrl = generator.InvitationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
|
||||
ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
|
||||
|
||||
emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl);
|
||||
if (ev.SendInvitationEmail)
|
||||
emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl);
|
||||
}
|
||||
else if (requiresEmailConfirmation)
|
||||
{
|
||||
|
@ -14,7 +14,8 @@ namespace BTCPayServer.Models.ServerViewModels
|
||||
public string Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
[Display(Name = "Invitation URL")]
|
||||
public string InvitationUrl { get; set; }
|
||||
[Display(Name = "Image")]
|
||||
public IFormFile ImageFile { get; set; }
|
||||
public string ImageUrl { get; set; }
|
||||
|
@ -1,5 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
@ -19,15 +21,35 @@ namespace BTCPayServer
|
||||
return await userManager.FindByIdAsync(idOrEmail);
|
||||
}
|
||||
|
||||
public static async Task<string> GenerateInvitationTokenAsync<TUser>(this UserManager<TUser> userManager, TUser user) where TUser : class
|
||||
public static async Task<string?> GenerateInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId) where TUser : class
|
||||
{
|
||||
return await userManager.GenerateUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose);
|
||||
var token = Guid.NewGuid().ToString("n")[..12];
|
||||
return await userManager.SetInvitationTokenAsync<TUser>(userId, token) ? token : null;
|
||||
}
|
||||
|
||||
public static async Task<TUser?> FindByInvitationTokenAsync<TUser>(this UserManager<TUser> userManager, string userId, string token) where TUser : class
|
||||
public static async Task<bool> UnsetInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId) where TUser : class
|
||||
{
|
||||
return await userManager.SetInvitationTokenAsync<TUser>(userId, null);
|
||||
}
|
||||
|
||||
private static async Task<bool> SetInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId, string? token) where TUser : class
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId);
|
||||
var isValid = user is not null && await userManager.VerifyUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose, token);
|
||||
if (user == null) return false;
|
||||
var blob = user.GetBlob() ?? new UserBlob();
|
||||
blob.InvitationToken = token;
|
||||
user.SetBlob(blob);
|
||||
await userManager.UpdateAsync(user);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static async Task<ApplicationUser?> FindByInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId, string token) where TUser : class
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId);
|
||||
var isValid = user is not null && (
|
||||
user.GetBlob()?.InvitationToken == token ||
|
||||
// backwards-compatibility with old tokens
|
||||
await userManager.VerifyUserTokenAsync(user, InvitationTokenProviderOptions.ProviderName, InvitationPurpose, token));
|
||||
return isValid ? user : null;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,9 @@
|
||||
@using BTCPayServer.TagHelpers
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@model BTCPayServer.Controllers.RegisterFromAdminViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(ServerNavPages.Users, "Create account");
|
||||
var canSendEmail = ViewData["CanSendEmail"] is true;
|
||||
}
|
||||
|
||||
<form method="post" asp-action="CreateUser">
|
||||
@ -55,6 +58,17 @@
|
||||
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex my-3">
|
||||
<input asp-for="SendInvitationEmail" type="checkbox" class="btcpay-toggle me-3" disabled="@(canSendEmail ? null : "disabled")" />
|
||||
<div>
|
||||
<label asp-for="SendInvitationEmail" class="form-check-label"></label>
|
||||
<span asp-validation-for="SendInvitationEmail" class="text-danger"></span>
|
||||
@if (!canSendEmail)
|
||||
{
|
||||
<div class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -55,6 +55,7 @@
|
||||
{ Disabled: true } => ("Disabled", "danger"),
|
||||
{ Approved: false } => ("Pending Approval", "warning"),
|
||||
{ EmailConfirmed: false } => ("Pending Email Verification", "warning"),
|
||||
{ InvitationUrl: not null } => ("Pending Invitation", "warning"),
|
||||
_ => ("Active", "success")
|
||||
};
|
||||
var detailsId = $"user_details_{user.Id}";
|
||||
@ -68,7 +69,7 @@
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>@user.Stores.Count() Store@(user.Stores.Count() == 1 ? "" : "s")</td>
|
||||
<td class="@(user.Stores.Any() ? null : "text-muted")">@user.Stores.Count() Store@(user.Stores.Count() == 1 ? "" : "s")</td>
|
||||
<td>@user.Created?.ToBrowserDate()</td>
|
||||
<td>
|
||||
<span class="user-status badge bg-@status.Item2">@status.Item1</span>
|
||||
@ -82,11 +83,11 @@
|
||||
{
|
||||
<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="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
|
||||
@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="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
|
||||
<a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
|
||||
</div>
|
||||
</td>
|
||||
@ -100,7 +101,21 @@
|
||||
</tr>
|
||||
<tr id="@detailsId" class="user-details-row collapse">
|
||||
<td colspan="6" class="border-top-0">
|
||||
@if (user.Stores.Any())
|
||||
@if (!string.IsNullOrEmpty(user.InvitationUrl))
|
||||
{
|
||||
<div class="payment-box m-0">
|
||||
<div class="qr-container">
|
||||
<vc:qr-code data="@user.InvitationUrl" />
|
||||
</div>
|
||||
<div class="input-group mt-3">
|
||||
<div class="form-floating">
|
||||
<vc:truncate-center text="@user.InvitationUrl" padding="15" elastic="true" classes="form-control-plaintext"/>
|
||||
<label>Invitation URL</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (user.Stores.Any())
|
||||
{
|
||||
<ul class="mb-0 p-0">
|
||||
@foreach (var store in user.Stores)
|
||||
@ -118,7 +133,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-secondary">No stores</span>
|
||||
<span class="text-secondary">No stores</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -21,6 +21,22 @@
|
||||
<button id="page-primary" name="command" type="submit" class="btn btn-primary" value="Save">Save</button>
|
||||
</div>
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.InvitationUrl))
|
||||
{
|
||||
<div class="payment-box mx-0 mb-5">
|
||||
<div class="qr-container">
|
||||
<vc:qr-code data="@Model.InvitationUrl" />
|
||||
</div>
|
||||
<div class="input-group mt-3">
|
||||
<div class="form-floating">
|
||||
<vc:truncate-center text="@Model.InvitationUrl" padding="15" elastic="true" classes="form-control-plaintext" id="InvitationUrl"/>
|
||||
<label for="InvitationUrl">Invitation URL</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="Name" class="form-label"></label>
|
||||
<input asp-for="Name" class="form-control" />
|
||||
|
Loading…
Reference in New Issue
Block a user