mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +01:00
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:
@@ -501,19 +501,19 @@ namespace BTCPayServer.Tests
|
||||
|
||||
// Check users list
|
||||
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);
|
||||
|
||||
// Check user which didn't require approval
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
|
||||
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(autoApproved.RegisterDetails.Email);
|
||||
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.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
|
||||
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"));
|
||||
|
||||
// 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")).SendKeys(unapproved.RegisterDetails.Email);
|
||||
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.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
|
||||
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("SaveUser")).Click();
|
||||
Assert.Contains("User successfully updated", s.FindAlertMessage().Text);
|
||||
// Check list again
|
||||
s.GoToServer(ServerNavPages.Users);
|
||||
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.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
|
||||
s.Logout();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Components.MainLogo
|
||||
@using BTCPayServer.Services
|
||||
@using BTCPayServer.Views.Server
|
||||
@using BTCPayServer.Views.Stores
|
||||
@inject BTCPayServerEnvironment Env
|
||||
@inject IFileService FileService
|
||||
@@ -53,7 +54,7 @@ else
|
||||
@foreach (var option in Model.Options)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
@if (Model.Options.Any())
|
||||
@@ -66,6 +67,10 @@ else
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,8 @@ namespace BTCPayServer.Controllers
|
||||
model.Roles = roleManager.Roles.ToDictionary(role => role.Id, role => role.Name);
|
||||
model.Users = await usersQuery
|
||||
.Include(user => user.UserRoles)
|
||||
.Include(user => user.UserStores)
|
||||
.ThenInclude(data => data.StoreData)
|
||||
.Skip(model.Skip)
|
||||
.Take(model.Count)
|
||||
.Select(u => new UsersViewModel.UserViewModel
|
||||
@@ -63,7 +65,8 @@ namespace BTCPayServer.Controllers
|
||||
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
|
||||
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
|
||||
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ using BTCPayServer.Hosting;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.ServerViewModels;
|
||||
using BTCPayServer.Models.StoreViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
@@ -122,6 +123,26 @@ namespace BTCPayServer.Controllers
|
||||
_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")]
|
||||
public IActionResult Maintenance()
|
||||
{
|
||||
|
||||
@@ -150,6 +150,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
HttpContext.SetStoreData(store);
|
||||
if (store.GetPermissionSet(userId).Contains(Policies.CanModifyStoreSettings, storeId))
|
||||
{
|
||||
return RedirectToAction("Dashboard", new { storeId });
|
||||
@@ -158,7 +159,6 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
return RedirectToAction("ListInvoices", "UIInvoice", new { storeId });
|
||||
}
|
||||
HttpContext.SetStoreData(store);
|
||||
return View();
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace BTCPayServer.Controllers
|
||||
_rateFactory = rateFactory;
|
||||
}
|
||||
|
||||
[HttpGet()]
|
||||
[HttpGet]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
|
||||
public async Task<IActionResult> ListStores(bool archived = false)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Stores;
|
||||
|
||||
namespace BTCPayServer.Models.ServerViewModels
|
||||
@@ -17,6 +18,7 @@ namespace BTCPayServer.Models.ServerViewModels
|
||||
public bool IsAdmin { get; set; }
|
||||
public DateTimeOffset? Created { get; set; }
|
||||
public IEnumerable<string> Roles { get; set; }
|
||||
public IEnumerable<UserStore> Stores { get; set; }
|
||||
}
|
||||
public List<UserViewModel> Users { get; set; } = new List<UserViewModel>();
|
||||
public override int CurrentPageCount => Users.Count;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Data;
|
||||
|
||||
namespace BTCPayServer.Models.StoreViewModels;
|
||||
|
||||
@@ -9,6 +10,7 @@ public class ListStoresViewModel
|
||||
public string StoreName { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
public bool Archived { get; set; }
|
||||
public List<UserStore> Users { get; set; }
|
||||
}
|
||||
|
||||
public List<StoreViewModel> Stores { get; set; } = new ();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
@@ -125,7 +126,10 @@ namespace BTCPayServer.Security
|
||||
|
||||
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)
|
||||
@@ -164,14 +168,15 @@ namespace BTCPayServer.Security
|
||||
{
|
||||
if (store != null)
|
||||
{
|
||||
_httpContext.SetStoreData(store);
|
||||
if (_httpContext.GetStoreData()?.Id != store.Id)
|
||||
_httpContext.SetStoreData(store);
|
||||
|
||||
// cache associated entities if present
|
||||
if (app != null)
|
||||
if (app != null && _httpContext.GetAppData()?.Id != app.Id)
|
||||
_httpContext.SetAppData(app);
|
||||
if (invoice != null)
|
||||
if (invoice != null && _httpContext.GetInvoiceData()?.Id != invoice.Id)
|
||||
_httpContext.SetInvoiceData(invoice);
|
||||
if (paymentRequest != null)
|
||||
if (paymentRequest != null && _httpContext.GetPaymentRequestData()?.Id != paymentRequest.Id)
|
||||
_httpContext.SetPaymentRequestData(paymentRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
|
||||
79
BTCPayServer/Views/UIServer/ListStores.cshtml
Normal file
79
BTCPayServer/Views/UIServer/ListStores.cshtml
Normal 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>
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
const string sortByDesc = "Sort by descending...";
|
||||
const string sortByAsc = "Sort by ascending...";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="mb-0">@ViewData["Title"]</h3>
|
||||
<a asp-action="CreateUser" class="btn btn-primary" role="button" id="CreateUser">
|
||||
@@ -46,9 +45,11 @@
|
||||
<span class="fa @(sortIconClass)"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>Stores</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th class="actions-col"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="UsersList">
|
||||
@@ -61,14 +62,18 @@
|
||||
{ EmailConfirmed: false } => ("Pending Email Verification", "warning"),
|
||||
_ => ("Active", "success")
|
||||
};
|
||||
<tr>
|
||||
<td class="d-flex align-items-center gap-2">
|
||||
<span class="user-email">@user.Email</span>
|
||||
@foreach (var role in user.Roles)
|
||||
{
|
||||
<span class="badge bg-info">@Model.Roles[role]</span>
|
||||
}
|
||||
var detailsId = $"user_details_{user.Id}";
|
||||
<tr id="user_@user.Id" class="user-overview-row mass-action-row">
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="user-email">@user.Email</span>
|
||||
@foreach (var role in user.Roles)
|
||||
{
|
||||
<span class="badge bg-info">@Model.Roles[role]</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>@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>
|
||||
@@ -90,6 +95,37 @@
|
||||
<a asp-action="DeleteUser" asp-route-userId="@user.Id">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 (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>
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace BTCPayServer.Views.Server
|
||||
public enum ServerNavPages
|
||||
{
|
||||
Index, Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files, Plugins,
|
||||
Roles
|
||||
Roles,
|
||||
Stores
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user