Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Additional improvements to the User Invitation flow #6233

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions BTCPayServer/Components/TruncateCenter/TruncateCenter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,8 @@ public IViewComponentResult Invoke(string text, string link = null, string class
};
if (!vm.IsVue)
{
vm.Start = vm.IsTruncated ? text[..padding] : text;
vm.Start = vm.IsTruncated && !vm.Elastic ? "{text[..padding]}…" : text;
vm.End = vm.IsTruncated ? text[^padding..] : string.Empty;
if (!vm.Elastic && vm.IsTruncated)
vm.Start = $"{vm.Start}…";
}
return View(vm);
}
Expand Down
27 changes: 16 additions & 11 deletions BTCPayServer/Controllers/UIAccountController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ public async Task<IActionResult> ConfirmEmail(string userId, string code)
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Your email has been confirmed."
});
await FinalizeInvitationIfApplicable(user);
return RedirectToAction(nameof(Login), new { email = user.Email });
}

Expand Down Expand Up @@ -797,6 +798,7 @@ public async Task<IActionResult> SetPassword(SetPasswordViewModel model)
Severity = StatusMessageModel.StatusSeverity.Success,
Message = hasPassword ? "Password successfully set." : "Account successfully created."
});
if (!hasPassword) await FinalizeInvitationIfApplicable(user);
return RedirectToAction(nameof(Login));
}

Expand All @@ -822,16 +824,6 @@ public async Task<IActionResult> AcceptInvite(string userId, string code)

var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
var requiresSetPassword = !await _userManager.HasPasswordAsync(user);

_eventAggregator.Publish(new UserInviteAcceptedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
});

// unset used token
await _userManager.UnsetInvitationTokenAsync<ApplicationUser>(user.Id);

if (requiresEmailConfirmation)
{
return await RedirectToConfirmEmail(user);
Expand All @@ -853,9 +845,22 @@ public async Task<IActionResult> AcceptInvite(string userId, string code)
Message = "Your password has been set by the user who invited you."
});

await FinalizeInvitationIfApplicable(user);
return RedirectToAction(nameof(Login), new { email = user.Email });
}


private async Task FinalizeInvitationIfApplicable(ApplicationUser user)
{
if (!_userManager.HasInvitationToken<ApplicationUser>(user)) return;
_eventAggregator.Publish(new UserInviteAcceptedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
});
// unset used token
await _userManager.UnsetInvitationTokenAsync<ApplicationUser>(user.Id);
}

private async Task<IActionResult> RedirectToConfirmEmail(ApplicationUser user)
{
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
Expand Down
4 changes: 4 additions & 0 deletions BTCPayServer/Controllers/UIStoresController.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel
var action = isExistingUser
? isExistingStoreUser ? "updated" : "added"
: "invited";
if (isExistingUser && !isExistingStoreUser)
{
successInfo = "The user is already registered on this server, so they didn't receive an invitation email.";
}
if (await _storeRepo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId))
{
TempData.SetStatusMessageModel(new StatusMessageModel
Expand Down
6 changes: 6 additions & 0 deletions BTCPayServer/UserManagerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ public static async Task<bool> UnsetInvitationTokenAsync<TUser>(this UserManager
return await userManager.SetInvitationTokenAsync<TUser>(userId, null);
}

public static bool HasInvitationToken<TUser>(this UserManager<ApplicationUser> userManager, ApplicationUser user, string? token = null) where TUser : class
{
var blob = user.GetBlob() ?? new UserBlob();
return token == null ? !string.IsNullOrEmpty(blob.InvitationToken) : blob.InvitationToken == token;
}

private static async Task<bool> SetInvitationTokenAsync<TUser>(this UserManager<ApplicationUser> userManager, string userId, string? token) where TUser : class
{
var user = await userManager.FindByIdAsync(userId);
Expand Down
23 changes: 16 additions & 7 deletions BTCPayServer/Views/Shared/ShowQR.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body text-center">
<div class="text-center my-2" :style="{height: `${qrOptions.height}px`}">
<component v-if="currentFragment" :is="currentMode.href ? 'a': 'div'" class="qr-container d-inline-block" :href="currentMode.href">
<qrcode :value="currentFragment" :options="qrOptions"></qrcode>
</component>
<div class="modal-body pt-0">
<div class="payment-box m-0">
<div class="qr-container justify-content-start">
<component v-if="currentFragment" :is="currentMode.href ? 'a': 'div'" class="qr-container d-inline-block" :href="currentMode.href">
<qrcode :value="currentFragment" :options="qrOptions"></qrcode>
</component>
</div>
<div v-if="currentFragment" class="input-group mt-3">
<div class="form-floating">
<vc:truncate-center text="currentFragment" padding="15" elastic="true" is-vue="true" classes="form-control-plaintext"/>
<label>{{title}}</label>
</div>
</div>
</div>
<ul class="nav btcpay-pills justify-content-center mt-4 mb-3" v-if="modes && Object.keys(modes).length > 1">
<li class="nav-item" v-for="(item, key) in modes">
Expand Down Expand Up @@ -107,8 +115,9 @@ function initQRShow(data) {
}
setTimeout(this.playNext, this.speed);
},
showData(data) {
this.modes = { default: { title: 'Default', fragments: [data] } };
showData(data, title) {
if (title) this.title = title;
this.modes = { default: { title: title || 'Default', fragments: [data] } };
this.mode = "default";
this.show();
},
Expand Down
114 changes: 63 additions & 51 deletions BTCPayServer/Views/UIServer/ListUsers.cshtml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@using BTCPayServer.Abstractions.Models
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model UsersViewModel
@{
ViewData.SetActivePage(ServerNavPages.Users);
Expand All @@ -9,10 +10,24 @@
"desc" => "asc",
_ => null
};

Csp.UnsafeEval();
const string sortByDesc = "Sort by email descending...";
const string sortByAsc = "Sort by email ascending...";
}

@section PageFootContent {
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script>
const qrApp = initQRShow({ title: "Invitation" });
delegate('click', '.view-invite', e => {
e.preventDefault();
const { invitationUrl, user } = e.target.dataset;
if (invitationUrl) qrApp.showData(invitationUrl, `Invitation for ${user}`);
});
</script>
}

<div class="sticky-header">
<h2 text-translate="true">@ViewData["Title"]</h2>
<a id="page-primary" asp-action="CreateUser" class="btn btn-primary" role="button">
Expand Down Expand Up @@ -44,7 +59,7 @@
<th>Created</th>
<th>Status</th>
<th class="actions-col"></th>
<th></th>
<th class="w-75px"></th>
</tr>
</thead>
<tbody id="UsersList">
Expand All @@ -58,7 +73,8 @@
{ InvitationUrl: not null } => ("Pending Invitation", "warning"),
_ => ("Active", "success")
};
var detailsId = $"user_details_{user.Id}";
var pendingInvite = !string.IsNullOrEmpty(user.InvitationUrl);
var detailsId = user.Stores.Any() ? $"user_details_{user.Id}" : null;
<tr id="[email protected]" class="user-overview-row mass-action-row">
<td>
<div class="d-flex align-items-center gap-2">
Expand All @@ -72,73 +88,68 @@
<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 [email protected]">@status.Item1</span>
<span class="user-status badge [email protected]">@status.Item1</span>
</td>
<td class="actions-col">
<div class="d-inline-flex align-items-center gap-3">
@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" class="text-nowrap">Resend email</a>
}
@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="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
@if (status.Item2 != "warning")
@if (pendingInvite)
{
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled"
class="@(user.Disabled ? "enable-user" : "disable-user")">@(user.Disabled ? "Enable" : "Disable")</a>
<a asp-action="User" asp-route-userId="@user.Id" class="view-invite text-nowrap" data-invitation-url="@user.InvitationUrl" data-user="@user.Email">View Invite</a>
}
<a asp-action="ResetUserPassword" asp-route-userId="@user.Id" class="reset-password">Password Reset</a>
else if(status.Item2 != "warning")
{
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled"
class="@(user.Disabled ? "enable-user" : "disable-user")">@(user.Disabled ? "Enable" : "Disable")</a>
}
<a asp-action="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
<a asp-action="ResetUserPassword" asp-route-userId="@user.Id" class="reset-password text-nowrap">Password Reset</a>
<a asp-action="DeleteUser" asp-route-userId="@user.Id" class="delete-user">Remove</a>
</div>
</td>
<td class="text-end">
<div class="d-inline-flex align-items-center gap-2">
<button class="accordion-button collapsed only-for-js ms-0 d-inline-block" type="button" data-bs-toggle="collapse" data-bs-target="#@detailsId" aria-expanded="false" aria-controls="@detailsId">
<vc:icon symbol="caret-down" />
</button>
</div>
</td>
</tr>
<tr id="@detailsId" class="user-details-row collapse">
<td colspan="6" class="border-top-0">
@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())
@if (detailsId != null)
{
<ul class="mb-0 p-0">
@foreach (var store in user.Stores)
{
<li class="py-1 d-flex gap-2">
<a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@store.StoreData.Id">@store.StoreData.StoreName</a>
<span class="badge bg-light">@store.StoreRoleId</span>
@if (store.StoreData.Archived)
{
<span class="badge bg-info">archived</span>
}
</li>
}
</ul>
}
else
{
<span class="text-secondary">No stores</span>
<button class="accordion-button w-auto collapsed only-for-js ms-0 d-inline-flex align-items-center gap-1" type="button" data-bs-toggle="collapse" data-bs-target="#@detailsId" aria-expanded="false" aria-controls="@detailsId">
<span>Details</span>
<vc:icon symbol="caret-down" css-class="ms-0" />
</button>
}
</td>
</tr>
@if (detailsId != null)
{
<tr id="@detailsId" class="user-details-row collapse">
<td colspan="6" class="border-top-0">
@if (user.Stores.Any())
{
<ul class="mb-0 p-0">
@foreach (var store in user.Stores)
{
<li class="py-1 d-flex gap-2">
<a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@store.StoreData.Id">@store.StoreData.StoreName</a>
<span class="badge bg-light">@store.StoreRoleId</span>
@if (store.StoreData.Archived)
{
<span class="badge bg-info">archived</span>
}
</li>
}
</ul>
}
else
{
<span class="text-secondary">No stores</span>
}
</td>
</tr>
}
}
</tbody>
</table>
Expand All @@ -147,3 +158,4 @@
<vc:pager view-model="Model"></vc:pager>

<partial name="_Confirm" model="@(new ConfirmModel("Send verification email", $"This will send a verification email to the user.", "Send"))" />
<partial name="ShowQR" />
2 changes: 1 addition & 1 deletion BTCPayServer/Views/UIServer/User.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

@if (!string.IsNullOrEmpty(Model.InvitationUrl))
{
<div class="payment-box mx-0 mb-5">
<div class="payment-box mx-0 mb-4">
<div class="qr-container">
<vc:qr-code data="@Model.InvitationUrl" />
</div>
Expand Down
Loading