Admin overview of the stores on the instance (#5745)

* Admin overview of the stores on the instance

POC/Draft for #5674.

* Enable admin to access foreign stores

* Remove stores list link

* UI updates

* Grant admins guest access to foreign stores

* Optimize cookie auth handler

* Test fix

* Revert changes related to StoreRepository.FindStore with isAdmin
This commit is contained in:
d11n
2024-02-23 09:51:41 +01:00
committed by GitHub
parent 5c98ca180a
commit d55770cc16
13 changed files with 193 additions and 27 deletions

View File

@@ -501,19 +501,19 @@ namespace BTCPayServer.Tests
// Check users list // Check users list
s.GoToServer(ServerNavPages.Users); s.GoToServer(ServerNavPages.Users);
var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.True(rows.Count >= 3); Assert.True(rows.Count >= 3);
// Check user which didn't require approval // Check user which didn't require approval
s.Driver.FindElement(By.Id("SearchTerm")).Clear(); s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(autoApproved.RegisterDetails.Email); s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(autoApproved.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter); s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows); Assert.Single(rows);
Assert.Contains(autoApproved.RegisterDetails.Email, rows.First().Text); Assert.Contains(autoApproved.RegisterDetails.Email, rows.First().Text);
s.Driver.ElementDoesNotExist(By.CssSelector("#UsersList tr:first-child .user-approved")); s.Driver.ElementDoesNotExist(By.CssSelector("#UsersList tr.user-overview-row:first-child .user-approved"));
// Edit view does not contain approve toggle // Edit view does not contain approve toggle
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click(); s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .user-edit")).Click();
s.Driver.ElementDoesNotExist(By.Id("Approved")); s.Driver.ElementDoesNotExist(By.Id("Approved"));
// Check user which still requires approval // Check user which still requires approval
@@ -521,22 +521,22 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("SearchTerm")).Clear(); s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(unapproved.RegisterDetails.Email); s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(unapproved.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter); s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows); Assert.Single(rows);
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text); Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
Assert.Contains("Pending Approval", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text); Assert.Contains("Pending Approval", s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .user-status")).Text);
// Approve user // Approve user
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click(); s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .user-edit")).Click();
s.Driver.FindElement(By.Id("Approved")).Click(); s.Driver.FindElement(By.Id("Approved")).Click();
s.Driver.FindElement(By.Id("SaveUser")).Click(); s.Driver.FindElement(By.Id("SaveUser")).Click();
Assert.Contains("User successfully updated", s.FindAlertMessage().Text); Assert.Contains("User successfully updated", s.FindAlertMessage().Text);
// Check list again // Check list again
s.GoToServer(ServerNavPages.Users); s.GoToServer(ServerNavPages.Users);
Assert.Contains(unapproved.RegisterDetails.Email, s.Driver.FindElement(By.Id("SearchTerm")).GetAttribute("value")); Assert.Contains(unapproved.RegisterDetails.Email, s.Driver.FindElement(By.Id("SearchTerm")).GetAttribute("value"));
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr")); rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows); Assert.Single(rows);
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text); Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
Assert.Contains("Active", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text); Assert.Contains("Active", s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .user-status")).Text);
// Finally, login user that needed approval // Finally, login user that needed approval
s.Logout(); s.Logout();

View File

@@ -2,6 +2,7 @@
@using BTCPayServer.Client @using BTCPayServer.Client
@using BTCPayServer.Components.MainLogo @using BTCPayServer.Components.MainLogo
@using BTCPayServer.Services @using BTCPayServer.Services
@using BTCPayServer.Views.Server
@using BTCPayServer.Views.Stores @using BTCPayServer.Views.Stores
@inject BTCPayServerEnvironment Env @inject BTCPayServerEnvironment Env
@inject IFileService FileService @inject IFileService FileService
@@ -53,7 +54,7 @@ else
@foreach (var option in Model.Options) @foreach (var option in Model.Options)
{ {
<li> <li>
<a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@option.Value" class="dropdown-item@(option.Selected ? " active" : "")" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a> <a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@option.Value" class="dropdown-item@(option.Selected && ViewData.IsActivePage(ServerNavPages.Stores) != "active" ? " active" : "")" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a>
</li> </li>
} }
@if (Model.Options.Any()) @if (Model.Options.Any())
@@ -66,6 +67,10 @@ else
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a asp-controller="UIUserStores" asp-action="ListStores" asp-route-archived="true" class="dropdown-item @ViewData.IsActivePage(StoreNavPages.Index)" id="StoreSelectorArchived">@Model.ArchivedCount Archived Store@(Model.ArchivedCount == 1 ? "" : "s")</a></li> <li><a asp-controller="UIUserStores" asp-action="ListStores" asp-route-archived="true" class="dropdown-item @ViewData.IsActivePage(StoreNavPages.Index)" id="StoreSelectorArchived">@Model.ArchivedCount Archived Store@(Model.ArchivedCount == 1 ? "" : "s")</a></li>
} }
@*
<li permission="@Policies.CanModifyServerSettings"><hr class="dropdown-divider"></li>
<li permission="@Policies.CanModifyServerSettings"><a asp-controller="UIServer" asp-action="ListStores" class="dropdown-item @ViewData.IsActivePage(ServerNavPages.Stores)" id="StoreSelectorAdminStores">Admin Store Overview</a></li>
*@
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -52,6 +52,8 @@ namespace BTCPayServer.Controllers
model.Roles = roleManager.Roles.ToDictionary(role => role.Id, role => role.Name); model.Roles = roleManager.Roles.ToDictionary(role => role.Id, role => role.Name);
model.Users = await usersQuery model.Users = await usersQuery
.Include(user => user.UserRoles) .Include(user => user.UserRoles)
.Include(user => user.UserStores)
.ThenInclude(data => data.StoreData)
.Skip(model.Skip) .Skip(model.Skip)
.Take(model.Count) .Take(model.Count)
.Select(u => new UsersViewModel.UserViewModel .Select(u => new UsersViewModel.UserViewModel
@@ -63,7 +65,8 @@ namespace BTCPayServer.Controllers
Approved = u.RequiresApproval ? u.Approved : null, Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created, Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId), Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
}) })
.ToListAsync(); .ToListAsync();

View File

@@ -20,6 +20,7 @@ using BTCPayServer.Hosting;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.ServerViewModels; using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
@@ -122,6 +123,26 @@ namespace BTCPayServer.Controllers
_transactionLinkProviders = transactionLinkProviders; _transactionLinkProviders = transactionLinkProviders;
} }
[HttpGet("server/stores")]
public async Task<IActionResult> ListStores()
{
var stores = await _StoreRepository.GetStores();
var vm = new ListStoresViewModel
{
Stores = stores
.Select(s => new ListStoresViewModel.StoreViewModel
{
StoreId = s.Id,
StoreName = s.StoreName,
Archived = s.Archived,
Users = s.UserStores
})
.OrderBy(s => !s.Archived)
.ToList()
};
return View(vm);
}
[HttpGet("server/maintenance")] [HttpGet("server/maintenance")]
public IActionResult Maintenance() public IActionResult Maintenance()
{ {

View File

@@ -150,6 +150,7 @@ namespace BTCPayServer.Controllers
{ {
return Forbid(); return Forbid();
} }
HttpContext.SetStoreData(store);
if (store.GetPermissionSet(userId).Contains(Policies.CanModifyStoreSettings, storeId)) if (store.GetPermissionSet(userId).Contains(Policies.CanModifyStoreSettings, storeId))
{ {
return RedirectToAction("Dashboard", new { storeId }); return RedirectToAction("Dashboard", new { storeId });
@@ -158,7 +159,6 @@ namespace BTCPayServer.Controllers
{ {
return RedirectToAction("ListInvoices", "UIInvoice", new { storeId }); return RedirectToAction("ListInvoices", "UIInvoice", new { storeId });
} }
HttpContext.SetStoreData(store);
return View(); return View();
} }

View File

@@ -35,7 +35,7 @@ namespace BTCPayServer.Controllers
_rateFactory = rateFactory; _rateFactory = rateFactory;
} }
[HttpGet()] [HttpGet]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
public async Task<IActionResult> ListStores(bool archived = false) public async Task<IActionResult> ListStores(bool archived = false)
{ {

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
namespace BTCPayServer.Models.ServerViewModels namespace BTCPayServer.Models.ServerViewModels
@@ -17,6 +18,7 @@ namespace BTCPayServer.Models.ServerViewModels
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
public DateTimeOffset? Created { get; set; } public DateTimeOffset? Created { get; set; }
public IEnumerable<string> Roles { get; set; } public IEnumerable<string> Roles { get; set; }
public IEnumerable<UserStore> Stores { get; set; }
} }
public List<UserViewModel> Users { get; set; } = new List<UserViewModel>(); public List<UserViewModel> Users { get; set; } = new List<UserViewModel>();
public override int CurrentPageCount => Users.Count; public override int CurrentPageCount => Users.Count;

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Data;
namespace BTCPayServer.Models.StoreViewModels; namespace BTCPayServer.Models.StoreViewModels;
@@ -9,6 +10,7 @@ public class ListStoresViewModel
public string StoreName { get; set; } public string StoreName { get; set; }
public string StoreId { get; set; } public string StoreId { get; set; }
public bool Archived { get; set; } public bool Archived { get; set; }
public List<UserStore> Users { get; set; }
} }
public List<StoreViewModel> Stores { get; set; } = new (); public List<StoreViewModel> Stores { get; set; } = new ();

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
@@ -125,7 +126,10 @@ namespace BTCPayServer.Security
if (!string.IsNullOrEmpty(storeId)) if (!string.IsNullOrEmpty(storeId))
{ {
store = await _storeRepository.FindStore(storeId, userId); var cachedStore = _httpContext.GetStoreData();
store = cachedStore?.Id == storeId
? cachedStore
: await _storeRepository.FindStore(storeId, userId);
} }
if (Policies.IsServerPolicy(policy) && isAdmin) if (Policies.IsServerPolicy(policy) && isAdmin)
@@ -164,14 +168,15 @@ namespace BTCPayServer.Security
{ {
if (store != null) if (store != null)
{ {
_httpContext.SetStoreData(store); if (_httpContext.GetStoreData()?.Id != store.Id)
_httpContext.SetStoreData(store);
// cache associated entities if present // cache associated entities if present
if (app != null) if (app != null && _httpContext.GetAppData()?.Id != app.Id)
_httpContext.SetAppData(app); _httpContext.SetAppData(app);
if (invoice != null) if (invoice != null && _httpContext.GetInvoiceData()?.Id != invoice.Id)
_httpContext.SetInvoiceData(invoice); _httpContext.SetInvoiceData(invoice);
if (paymentRequest != null) if (paymentRequest != null && _httpContext.GetPaymentRequestData()?.Id != paymentRequest.Id)
_httpContext.SetPaymentRequestData(paymentRequest); _httpContext.SetPaymentRequestData(paymentRequest);
} }
} }

View File

@@ -201,6 +201,18 @@ namespace BTCPayServer.Services.Stores
}; };
} }
public async Task<StoreData[]> GetStores(IEnumerable<string>? storeIds = null)
{
await using var ctx = _ContextFactory.CreateContext();
return await ctx.Stores
.Where(s => storeIds == null || storeIds.Contains(s.Id))
.Include(data => data.UserStores)
.ThenInclude(data => data.StoreRole)
.Include(data => data.UserStores)
.ThenInclude(data => data.ApplicationUser)
.ToArrayAsync();
}
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string>? storeIds = null) public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string>? storeIds = null)
{ {
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();

View File

@@ -0,0 +1,79 @@
@model BTCPayServer.Models.StoreViewModels.ListStoresViewModel
@{
Layout = "_Layout";
ViewData.SetActivePage(ServerNavPages.Stores, "Store Overview");
}
<partial name="_StatusMessage" />
<div class="d-sm-flex justify-content-between mb-2">
<h2 class="mb-0">
@ViewData["Title"]
</h2>
</div>
@if (Model.Stores.Any())
{
<table class="table table-hover">
<thead>
<tr>
<th>Store</th>
<th>Users</th>
</tr>
</thead>
<tbody>
@foreach (var store in Model.Stores)
{
var detailsId = $"store_details_{store.StoreId}";
<tr id="store_@store.StoreId" class="mass-action-row">
<td>
<a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@store.StoreId" id="Store-@store.StoreId">
@store.StoreName
</a>
@if (store.Archived)
{
<span class="badge bg-info ms-2">archived</span>
}
</td>
<td>@store.Users.Count User@(store.Users.Count == 1 ? "" : "s")</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="store-details-row collapse">
<td colspan="5" class="border-top-0">
@if (store.Users.Any())
{
<ul class="mb-0 p-0">
@foreach (var user in store.Users)
{
<li class="py-1 d-flex gap-2">
<a asp-controller="UIServer" asp-action="User" asp-route-userId="@user.ApplicationUser.Id">@user.ApplicationUser.Email</a>
<span class="badge bg-light">@user.StoreRoleId</span>
@if (store.Archived)
{
<span class="badge bg-info">archived</span>
}
</li>
}
</ul>
}
else
{
<span class="text-secondary">No users</span>
}
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-secondary mt-3">
There are no stores yet.
</p>
}

View File

@@ -19,7 +19,6 @@
const string sortByDesc = "Sort by descending..."; const string sortByDesc = "Sort by descending...";
const string sortByAsc = "Sort by ascending..."; const string sortByAsc = "Sort by ascending...";
} }
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3> <h3 class="mb-0">@ViewData["Title"]</h3>
<a asp-action="CreateUser" class="btn btn-primary" role="button" id="CreateUser"> <a asp-action="CreateUser" class="btn btn-primary" role="button" id="CreateUser">
@@ -46,9 +45,11 @@
<span class="fa @(sortIconClass)"></span> <span class="fa @(sortIconClass)"></span>
</a> </a>
</th> </th>
<th>Stores</th>
<th>Created</th> <th>Created</th>
<th>Status</th> <th>Status</th>
<th class="actions-col"></th> <th class="actions-col"></th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody id="UsersList"> <tbody id="UsersList">
@@ -61,14 +62,18 @@
{ EmailConfirmed: false } => ("Pending Email Verification", "warning"), { EmailConfirmed: false } => ("Pending Email Verification", "warning"),
_ => ("Active", "success") _ => ("Active", "success")
}; };
<tr> var detailsId = $"user_details_{user.Id}";
<td class="d-flex align-items-center gap-2"> <tr id="user_@user.Id" class="user-overview-row mass-action-row">
<span class="user-email">@user.Email</span> <td>
@foreach (var role in user.Roles) <div class="d-flex align-items-center gap-2">
{ <span class="user-email">@user.Email</span>
<span class="badge bg-info">@Model.Roles[role]</span> @foreach (var role in user.Roles)
} {
<span class="badge bg-info">@Model.Roles[role]</span>
}
</div>
</td> </td>
<td>@user.Stores.Count() Store@(user.Stores.Count() == 1 ? "" : "s")</td>
<td>@user.Created?.ToBrowserDate()</td> <td>@user.Created?.ToBrowserDate()</td>
<td> <td>
<span class="user-status badge bg-@status.Item2">@status.Item1</span> <span class="user-status badge bg-@status.Item2">@status.Item1</span>
@@ -90,6 +95,37 @@
<a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a> <a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
</div> </div>
</td> </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 (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> </tr>
} }
</tbody> </tbody>

View File

@@ -3,6 +3,7 @@ namespace BTCPayServer.Views.Server
public enum ServerNavPages public enum ServerNavPages
{ {
Index, Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files, Plugins, Index, Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files, Plugins,
Roles Roles,
Stores
} }
} }