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:
d11n 2024-09-12 05:31:57 +02:00 committed by GitHub
parent 3342122be2
commit f3d485da53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 134 additions and 34 deletions

View File

@ -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; }
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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)
{

View File

@ -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; }

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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" />