Feature: New global search bar to improve navigation (#7183)
What changed, and why it matters
This commit adds a new global search bar and reorganizes the top navigation in BTCPay Server. Most of the change is user-interface code (menus, CSS, JavaScript, tests). There is one small security-relevant detail: the new search endpoint returns a list of internal URLs and checks whether the current user is authorized for each one, but it also accepts an arbitrary storeId parameter and queries the store with the current user's permissions. The commit does not describe itself as a security fix, and no vulnerability is clearly demonstrated in the diff.
Treat this as a regular feature commit. As a defensive measure, reviewers should verify that /search/global: (1) validates the storeId parameter and does not allow cross-store enumeration, (2) applies rate limiting to prevent abuse of the search/invoice lookup, (3) ensures the q parameter cannot be used for injection into InvoiceQuery, and (4) confirms that authorization checks are enforced on the target pages and not only on search suggestions. No immediate patch is indicated by the diff alone.
Security signals we found
New authenticated endpoint /search/global returns internal URLs and metadata
Endpoint accepts user-supplied storeId and resolves it with current user's authorization context
Search results are filtered by RequiredPolicy using the authorization service
ResultItemViewModel.RequiredPolicy is marked [JsonIgnore] so it is not leaked to the client
Tests assert authorization boundary: admin-only routes hidden from non-admin users
No input validation/sanitization of q parameter is visible in the diff
No rate-limiting or anti-CSRF considerations visible for the search endpoint
Refactor of navigation moved permission-gated menu items to a new GlobalNav component using the same permission attributes
Evidence from the diff
The commit introduces a GlobalSearch plugin with a /search/global endpoint (UISearchController.Global) that aggregates ResultItemViewModel entries from multiple ISearchResultItemProvider implementations. Each item has a RequiredPolicy and the SearchResultItemProviders class filters results via FilterAuthorizedItems using the authorization service. The endpoint accepts q, storeId, take, and hash parameters. When storeId is supplied, the controller calls storeRepository.FindStore(storeId, User). The search results include URLs generated with IUrlHelper.Action and are returned as JSON. The commit also refactors MainNav/GlobalNav, moves server/account menus to a top-right global nav, and adds Playwright tests verifying that non-admin users do not see admin-only routes in search suggestions.
Changed components
BTCPayServer.Plugins.GlobalSearchBTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.csBTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.csBTCPayServer/Plugins/GlobalSearch/Views/NavExtension.cshtmlBTCPayServer/Components/GlobalNavBTCPayServer/Components/MainNavBTCPayServer/Views/Shared/_Layout.cshtmlBTCPayServer.Tests/GlobalSearchTests.csInspect captured patch +2753 / −294
diff --git a/BTCPayServer.Tests/ApiKeysTests.cs b/BTCPayServer.Tests/ApiKeysTests.cs
index cad6b07..015ba98 100644
--- a/BTCPayServer.Tests/ApiKeysTests.cs
+++ b/BTCPayServer.Tests/ApiKeysTests.cs
@@ -355,7 +355,7 @@ namespace BTCPayServer.Tests
await s.GoToLogin();
await s.LogIn(user2.RegisterDetails.Email, user2.RegisterDetails.Password);
- await s.GoToUrl($"api-keys/{user1ApiKey}/view-analysis");
+ await s.GoToUrl($"api-keys/{user1ApiKey}/view-analysis", true);
await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
Assert.Contains("404", await s.Page.ContentAsync());
}
diff --git a/BTCPayServer.Tests/GlobalSearchTests.cs b/BTCPayServer.Tests/GlobalSearchTests.cs
new file mode 100644
index 0000000..9398cae
--- /dev/null
+++ b/BTCPayServer.Tests/GlobalSearchTests.cs
@@ -0,0 +1,96 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Events;
+using BTCPayServer.Payments;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using NBitcoin;
+using Newtonsoft.Json;
+using Xunit;
+using Xunit.Abstractions;
+using static Microsoft.Playwright.Assertions;
+
+namespace BTCPayServer.Tests;
+
+public class GlobalSearchTests(ITestOutputHelper helper) : UnitTestBase(helper)
+{
+ [Fact]
+ [Trait("Playwright", "Playwright-2")]
+ public async Task TestGlobalSearch()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.RegisterNewUser(isAdmin: true);
+ await s.CreateNewStore();
+
+ await s.GlobalSearch.GoToPage("Setup wallet");
+ await s.Page.WaitForURLAsync(s.ServerUri + $"stores/{s.StoreId}/onchain/BTC");
+ var admin = (s.CreatedUser, s.Password);
+
+ // Create a new invoice and check that you can search for it either via invoice id, bitcoin address or transaction id.
+ await s.AddDerivationScheme();
+ var invoiceId = await s.CreateInvoice(amount: 0.01m, currency: "BTC");
+ var invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId);
+ var address = invoice.GetPaymentPrompt(PaymentTypes.CHAIN.GetPaymentMethodId("BTC"))!.Destination;
+ Assert.NotNull(address);
+
+ await SearchAndOpenInvoice(invoiceId);
+ await SearchAndOpenInvoice(address);
+
+ var txId = uint256.Zero;
+ await s.Server.WaitForEvent<NewOnChainTransactionEvent>(async () =>
+ {
+ txId = await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address!, s.Server.ExplorerNode.Network), Money.Coins(0.001m));
+ });
+ await SearchAndOpenInvoice(txId.ToString());
+
+ // Go back to home, and check that you can search by clicking rather that typing.
+ await s.GoToHome();
+ await s.GlobalSearch.Fill("users");
+ var searchItem = await s.GlobalSearch.AssertShow("View server's registered users");
+ await searchItem.ClickAsync();
+ await s.Page.WaitForURLAsync(s.ServerUri + "server/users");
+
+ // Now check that you can see some admin only route in the search suggestions
+ await s.GoToHome();
+ await s.GlobalSearch.Fill("server settings");
+ await s.GlobalSearch.AssertShow("Configure the server settings");
+
+ // Logout, create a new non admin user
+ var nonAdmin = s.Server.NewAccount();
+ await nonAdmin.GrantAccessAsync();
+ await nonAdmin.MakeAdmin(false);
+ await s.Logout();
+ await s.GoToLogin();
+ await s.LogIn(nonAdmin.RegisterDetails.Email, nonAdmin.RegisterDetails.Password);
+ await s.GoToHome();
+
+ // Check that you can't search for admin only routes
+ await s.GlobalSearch.Fill("server settings");
+ await Expect(s.GlobalSearch.GetResultLocator("Configure the server settings")).Not.ToBeVisibleAsync();
+
+ await s.Logout();
+ await s.LogIn(admin.CreatedUser, admin.Password);
+ // Access UISearchController.Global route, and check that all the routes are accessible
+ var response = await s.Page.Context.APIRequest.GetAsync(s.Link($"/search/global?storeId={s.StoreId}"));
+ Assert.True(response.Ok, $"Global search endpoint returned {response.Status}: {await response.TextAsync()}");
+ var items = JsonConvert.DeserializeObject<List<ResultItemViewModel>>(await response.TextAsync());
+ Assert.NotNull(items);
+ Assert.NotEmpty(items);
+
+ foreach (var item in items.Where(item => !string.IsNullOrEmpty(item.Url)))
+ {
+ await s.GoToUrl(item.Url);
+ await s.Page.AssertNoError();
+ }
+
+ async Task SearchAndOpenInvoice(string query)
+ {
+ await s.GoToHome();
+ await s.GlobalSearch.Fill(query);
+ await s.GlobalSearch.AssertShow("Invoice");
+ await s.GlobalSearch.Enter();
+ await s.Page.WaitForURLAsync(s.ServerUri + $"invoices/{invoiceId}");
+ }
+ }
+}
diff --git a/BTCPayServer.Tests/PMO/GlobalSearchPMO.cs b/BTCPayServer.Tests/PMO/GlobalSearchPMO.cs
new file mode 100644
index 0000000..2d2942c
--- /dev/null
+++ b/BTCPayServer.Tests/PMO/GlobalSearchPMO.cs
@@ -0,0 +1,40 @@
+using System.Threading.Tasks;
+using Microsoft.Playwright;
+using static Microsoft.Playwright.Assertions;
+
+namespace BTCPayServer.Tests.PMO;
+
+public class GlobalSearchPMO(PlaywrightTester tester)
+{
+ IPage Page => tester.Page;
+ /// <summary>
+ /// To use to nagivate to a static search item (such as a page quickly)
+ /// </summary>
+ /// <param name="page"></param>
+ public async Task GoToPage(string page)
+ {
+ await Page.Keyboard.PressAsync("/");
+ await Page.Keyboard.TypeAsync(page);
+ await Page.Keyboard.PressAsync("Enter");
+ }
+
+ public async Task Fill(string query)
+ {
+ await Page.Keyboard.PressAsync("/");
+ await Page.Locator("#globalSearchInput").FillAsync(query);
+ await Page.Locator("#globalSearchResults:not([hidden])").WaitForAsync();
+ }
+
+ public Task Enter() => Page.Keyboard.PressAsync("Enter");
+
+
+ public ILocator GetResultLocator(string partialText)
+ => Page.Locator("#globalSearchResults .globalSearch-item", new() { HasTextString = partialText });
+
+ public async Task<ILocator> AssertShow(string partialText)
+ {
+ var locator = GetResultLocator(partialText);
+ await Expect(GetResultLocator(partialText)).ToBeVisibleAsync();
+ return locator;
+ }
+}
diff --git a/BTCPayServer.Tests/POSTests.cs b/BTCPayServer.Tests/POSTests.cs
index 2a58cc8..675e17a 100644
--- a/BTCPayServer.Tests/POSTests.cs
+++ b/BTCPayServer.Tests/POSTests.cs
@@ -1227,7 +1227,7 @@ goodies:
var archivedText = await archivedLink.TextContentAsync();
Assert.Contains("1 Archived App", archivedText);
- await s.GoToUrl(posBaseUrl);
+ await s.GoToUrl(posBaseUrl, true);
var title = await s.Page.TitleAsync();
Assert.Contains("Page not found", title, StringComparison.OrdinalIgnoreCase);
await s.Page.GoBackAsync();
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index f95623f..2ba5cb0 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -10,6 +10,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
+using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
@@ -196,9 +197,13 @@ namespace BTCPayServer.Tests
}
}
- public async Task GoToUrl(string uri)
+ public async Task GoToUrl(string uri, bool ignoreResponse = false)
{
- await Page.GotoAsync(Link(uri), new() { WaitUntil = WaitUntilState.Commit });
+ var response = await Page.GotoAsync(Link(uri), new() { WaitUntil = WaitUntilState.Commit });
+ if (response is null || ignoreResponse)
+ // Can be null of navigating to a fragment (with #)
+ return;
+ Assert.True(response.Ok is true, $"Unable to navigate to {uri} ({response.Status}: {response.StatusText})");
}
public string Link(string uri)
@@ -378,11 +383,22 @@ namespace BTCPayServer.Tests
public async Task GoToProfile(string navPages)
{
await Page.ClickAsync("#menu-item-Account");
- await Page.ClickAsync("#Nav-ManageAccount");
- if (navPages != nameof(ManageNavPages.Index))
+ if (navPages == nameof(ManageNavPages.Index))
{
- await Page.ClickAsync($"#menu-item-{navPages}");
+ await Page.ClickAsync("#globalNavAccountMenu #Nav-ManageAccount");
+ return;
}
+
+ var accountNavSelector = $"#globalNavAccountMenu #menu-item-{navPages}";
+ var accountNavItem = Page.Locator(accountNavSelector);
+ if (await accountNavItem.CountAsync() > 0 && await accountNavItem.First.IsVisibleAsync())
+ {
+ await accountNavItem.First.ClickAsync();
+ return;
+ }
+
+ await Page.ClickAsync("#globalNavAccountMenu #Nav-ManageAccount");
+ await Page.ClickAsync($"#menu-item-{navPages}");
}
public Task GoToServer(ServerNavPages navPages = ServerNavPages.Policies)
@@ -390,15 +406,21 @@ namespace BTCPayServer.Tests
public async Task GoToServer(string navPages)
{
- await Page.ClickAsync("#menu-item-Policies");
- if (navPages == nameof(ServerNavPages.Emails))
+ if (navPages == nameof(ServerNavPages.Plugins))
{
- await Page.ClickAsync($"#menu-item-Server-{navPages}");
+ await Page.ClickAsync("#globalNavPluginsToggle");
+ await Page.ClickAsync("#globalNavPluginsMenu #menu-item-Plugins");
+ return;
}
- else if (navPages != nameof(ServerNavPages.Policies))
+
+ await Page.ClickAsync("#globalNavServerToggle");
+ if (navPages == nameof(ServerNavPages.Emails))
{
- await Page.ClickAsync($"#menu-item-{navPages}");
+ await Page.ClickAsync("#globalNavServerMenu #menu-item-Server-Emails");
+ return;
}
+
+ await Page.ClickAsync($"#globalNavServerMenu #menu-item-{navPages}");
}
public async Task ClickOnAllSectionLinks(string sectionSelector = "#menu-item")
@@ -545,11 +567,14 @@ namespace BTCPayServer.Tests
StoreId = storeId;
if (WalletId != null)
WalletId = new WalletId(storeId, WalletId.CryptoCode);
- if (storeNavPage != StoreNavPages.General)
- await Page.Locator($"#menu-item-{StoreNavPages.General}").ClickAsync();
}
- await Page.Locator($"#menu-item-{storeNavPage}").ClickAsync();
+ var storeNavSelector = $"#mainNav #menu-item-{storeNavPage}";
+ var storeNavItem = Page.Locator(storeNavSelector);
+ if (storeNavPage != StoreNavPages.General && !await storeNavItem.First.IsVisibleAsync())
+ await Page.Locator($"#mainNav #menu-item-{StoreNavPages.General}").ClickAsync();
+
+ await storeNavItem.ClickAsync();
}
public async Task ClickCancel()
@@ -885,11 +910,16 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("- Denied</h", content);
// check associated link is active if present
var hrefToMatch = new Uri(Link(url), UriKind.Absolute).AbsolutePath.TrimEnd('/');
- var sidebarLink = Page.Locator($"#mainNav a[href=\"{hrefToMatch}\"], #mainNav a[href=\"{hrefToMatch}/\"]");
+ var sidebarLink = Page.Locator($"#mainNav a[href=\"{hrefToMatch}\"], #StoreSelectorMenu a[href=\"{hrefToMatch}/\"]");
if (await sidebarLink.CountAsync() > 0)
{
- var classAttr = await sidebarLink.First.GetAttributeAsync("class");
- Assert.Contains("active", classAttr);
+ foreach (var link in await sidebarLink.AllAsync())
+ {
+ var classAttr = await link.GetAttributeAsync("class");
+ if (classAttr?.Contains("active") is true)
+ return;
+ }
+ throw new Exception($"Link {hrefToMatch} is not active");
}
}
else
@@ -950,5 +980,7 @@ namespace BTCPayServer.Tests
public Task ElementDoesNotExist(string selector)
=> Expect(Page.Locator(selector)).ToHaveCountAsync(0);
+
+ public GlobalSearchPMO GlobalSearch => new GlobalSearchPMO(this);
}
}
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index a352801..e38994e 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -56,7 +56,7 @@ namespace BTCPayServer.Tests
await s.ClickOnAllSectionLinks("#mainNavSettings");
await s.GoToServer(ServerNavPages.Services);
s.TestLogs.LogInformation("Let's check if we can access the logs");
- await s.Page.GetByRole(AriaRole.Link, new() { Name = "Logs" }).ClickAsync();
+ await s.GoToServer(ServerNavPages.Logs);
await s.Page.Locator("a:has-text('.log')").First.ClickAsync();
Assert.Contains("Starting listening NBXplorer", await s.Page.ContentAsync());
}
@@ -858,7 +858,7 @@ namespace BTCPayServer.Tests
await s.Page.ClickAsync("#disable");
await s.Page.FillAsync("#ConfirmInput", "DISABLE");
await s.Page.ClickAsync("#ConfirmContinue");
- await s.GoToUrl("/server/services/ssh");
+ await s.GoToUrl("/server/services/ssh", true);
Assert.True((await s.Page.ContentAsync()).Contains("404 - Page not found", StringComparison.OrdinalIgnoreCase));
policies = await settings.GetSettingAsync<PoliciesSettings>();
@@ -1970,7 +1970,7 @@ namespace BTCPayServer.Tests
await s.ClickPagePrimary();
var o = s.Page.Context.WaitForPageAsync();
- await s.Page.ClickAsync("text=View");
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
var newPage = await o;
var address = await s.Server.ExplorerNode.GetNewAddressAsync();
@@ -2294,21 +2294,21 @@ namespace BTCPayServer.Tests
await s.GoToHome();
await s.Logout();
- await s.GoToUrl($"/i/{i}/receipt");
+ await s.GoToUrl($"/i/{i}/receipt", true);
await TestUtils.EventuallyAsync(async () =>
{
var title = await s.Page.TitleAsync();
Assert.Contains("Page not found", title, StringComparison.OrdinalIgnoreCase);
});
- await s.GoToUrl($"/i/{i}");
+ await s.GoToUrl($"/i/{i}", true);
await TestUtils.EventuallyAsync(async () =>
{
var title = await s.Page.TitleAsync();
Assert.Contains("Page not found", title, StringComparison.OrdinalIgnoreCase);
});
- await s.GoToUrl($"/i/{i}/status");
+ await s.GoToUrl($"/i/{i}/status", true);
await TestUtils.EventuallyAsync(async () =>
{
var title = await s.Page.TitleAsync();
diff --git a/BTCPayServer.Tests/PullPaymentsTests.cs b/BTCPayServer.Tests/PullPaymentsTests.cs
index 10de638..44b21ce 100644
--- a/BTCPayServer.Tests/PullPaymentsTests.cs
+++ b/BTCPayServer.Tests/PullPaymentsTests.cs
@@ -225,7 +225,6 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.FillAsync("#Amount", payoutAmount.ToString());
await s.Page.FillAsync("#Currency", "BTC");
await s.ClickPagePrimary();
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
string bolt;
PayoutData payout;
await using (await s.SwitchPage(async () =>
@@ -1031,7 +1030,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.ClickPagePrimary();
var opening = s.Page.Context.WaitForPageAsync();
- await s.Page.ClickAsync("text=View");
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
var newPage = await opening;
await Expect(newPage.Locator("body")).ToContainTextAsync("PP1");
await newPage.CloseAsync();
@@ -1046,7 +1045,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.FindAlertMessage();
await using (await s.SwitchPage(async () => {
- await s.Page.ClickAsync("text=View");
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
}))
{
try
diff --git a/BTCPayServer/Blazor/NotificationsDropDown.razor b/BTCPayServer/Blazor/NotificationsDropDown.razor
index c616b5b..9cd3436 100644
--- a/BTCPayServer/Blazor/NotificationsDropDown.razor
+++ b/BTCPayServer/Blazor/NotificationsDropDown.razor
@@ -12,29 +12,36 @@
@inject BTCPayServerOptions _BTCPayServerOptions
@inject EventAggregator _EventAggregator
-<div id="Notifications" class="@(!_JSRuntime.IsPreRendering() ? "rendered" : "")">
- @if (UnseenCount == "0")
- {
- <a href="@NotificationsUrl" id="NotificationsHandle" class="mainMenuButton" title="@StringLocalizer["Notifications"]">
- <Icon Symbol="nav-notifications" />
- </a>
- }
- else
- {
- <button id="NotificationsHandle" class="mainMenuButton" title="@StringLocalizer["Notifications"]" type="button" data-bs-toggle="dropdown">
- <Icon Symbol="nav-notifications" />
+<div id="Notifications" class="dropdown @(!_JSRuntime.IsPreRendering() ? "rendered" : "")">
+ <button id="NotificationsHandle"
+ class="mainMenuButton globalNav-btn dropdown-toggle"
+ title="@StringLocalizer["Notifications"]"
+ type="button"
+ data-bs-toggle="dropdown"
+ data-bs-display="static"
+ data-bs-auto-close="outside"
+ data-bs-trigger="hover"
+ data-bs-placement="bottom"
+ data-global-nav-tooltip="1"
+ aria-expanded="false">
+ <Icon Symbol="nav-notifications" />
+ @if (HasUnseenNotifications)
+ {
<span class="badge rounded-pill bg-danger p-1 ms-1" id="NotificationsBadge">@UnseenCount</span>
- </button>
- }
- @if (UnseenCount != "0" && Last5 is not null)
- {
- <div class="dropdown-menu text-center" id="NotificationsDropdown" aria-labelledby="NotificationsHandle">
- <div class="d-flex gap-3 align-items-center justify-content-between py-3 px-4 border-bottom border-light">
- <h5 class="m-0" text-translate="true">Notifications</h5>
+ }
+ </button>
+ <div class="dropdown-menu text-center" id="NotificationsDropdown" aria-labelledby="NotificationsHandle">
+ <div class="d-flex gap-3 align-items-center justify-content-between py-3 px-4 border-bottom border-light">
+ <h5 class="m-0" text-translate="true">Notifications</h5>
+ @if (HasUnseenNotifications)
+ {
<a class="btn btn-link p-0" @onclick="MarkAllAsSeen" id="NotificationsMarkAllAsSeen" text-translate="true">Mark all as seen</a>
- </div>
- <div id="NotificationsList" v-pre>
- @foreach (var n in Last5)
+ }
+ </div>
+ <div id="NotificationsList" v-pre>
+ @if (Last5 is { Count: > 0 })
+ {
+ foreach (var n in Last5)
{
<a href="@NotificationUrl(n.Id)" class="notification d-flex align-items-center dropdown-item border-bottom border-light py-3 px-4" @key="@n.Id">
<div class="me-3">
@@ -50,13 +57,16 @@
</div>
</a>
}
- </div>
-
- <div class="p-3">
- <a href="@NotificationsUrl" text-translate="true">View all</a>
- </div>
+ }
+ else
+ {
+ <div class="px-4 py-4 text-start text-muted" text-translate="true">No new notifications</div>
+ }
</div>
- }
+ <div class="p-3 border-top border-light">
+ <a href="@NotificationsUrl" text-translate="true">View all</a>
+ </div>
+ </div>
</div>
@code {
@@ -64,6 +74,7 @@
string NotificationUrl(string notificationId) => _LinkGenerator.GetPathByAction("NotificationPassThrough", "UINotifications", values: new { id = notificationId }, pathBase: _BTCPayServerOptions.RootPath);
string UnseenCount;
List<NotificationViewModel> Last5;
+ bool HasUnseenNotifications => UnseenCount is not null && UnseenCount != "0";
IDisposable _EventAggregatorListener;
protected override void OnInitialized()
{
@@ -77,7 +88,6 @@
{
var res = await _NotificationManager.GetSummaryNotifications(userId, cachedOnly: false);
UpdateState(res);
- StateHasChanged();
}
});
});
diff --git a/BTCPayServer/Components/GlobalNav/Default.cshtml b/BTCPayServer/Components/GlobalNav/Default.cshtml
new file mode 100644
index 0000000..b0560fe
--- /dev/null
+++ b/BTCPayServer/Components/GlobalNav/Default.cshtml
@@ -0,0 +1,195 @@
+@using BTCPayServer.Views.Server
+@using BTCPayServer.Views.Manage
+@using BTCPayServer.Client
+@using BTCPayServer.Plugins
+@using BTCPayServer.Services
+@using BTCPayServer.Plugins.Emails
+@using BTCPayServer.Plugins.Translations
+@inject ThemeSettings Theme
+@inject PluginService PluginService
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+
+@model BTCPayServer.Components.GlobalNav.GlobalNavViewModel
+
+@{
+ var pluginsUrl = Url.Action("ListPlugins", "UIServer");
+ var loginName = User.Identity?.Name;
+ var accountDisplayName = string.IsNullOrEmpty(Model.UserName)
+ ? loginName ?? string.Empty
+ : string.IsNullOrEmpty(loginName)
+ ? Model.UserName
+ : $"{Model.UserName} ({loginName})";
+}
+
+<div id="globalNav" data-store-id="@Model.CurrentStoreId">
+ <vc:ui-extension-point location="global-nav" model="Model"></vc:ui-extension-point>
+ <div id="mainNavSettings" class="d-flex align-items-center gap-1">
+ <vc:ui-extension-point location="global-nav-icons" model="Model"></vc:ui-extension-point>
+ <component type="typeof(BTCPayServer.Blazor.NotificationsDropDown)" render-mode="ServerPrerendered" />
+
+ <div class="dropdown" permission="@Policies.CanModifyServerSettings">
+ <button id="globalNavPluginsToggle"
+ class="globalNav-btn"
+ type="button"
+ data-bs-toggle="dropdown"
+ aria-expanded="false"
+ title="@StringLocalizer["Plugins"]"
+ data-bs-trigger="hover"
+ data-bs-placement="bottom"
+ data-global-nav-tooltip="1">
+ <vc:icon symbol="nav-plugin" />
+ @if (PluginService.GetDisabledPlugins().Any())
+ {
+ <span class="btcpay-status btcpay-status--disabled globalNav-status" aria-hidden="true"></span>
+ }
+ </button>
+ <ul id="globalNavPluginsMenu" class="dropdown-menu dropdown-menu-end py-0">
+ @if (!string.IsNullOrEmpty(pluginsUrl))
+ {
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Plugins)" asp-area="" asp-controller="UIServer" asp-action="ListPlugins" text-translate="true">Manage Plugins</a>
+ </li>
+ <li class="py-1 px-3">
+ <a class="dropdown-item nav-link" asp-area="" asp-controller="UIServer" asp-action="ListPlugins" asp-fragment="plugins-installed" text-translate="true">Installed Plugins</a>
+ </li>
+ <li class="py-1 px-3">
+ <a class="dropdown-item nav-link" asp-area="" asp-controller="UIServer" asp-action="ListPlugins" asp-fragment="plugins-directory" text-translate="true">Plugin Directory</a>
+ </li>
+ }
+ </ul>
+ </div>
+
+ <div class="dropdown" permission="@Policies.CanModifyServerSettings">
+ <button id="globalNavServerToggle"
+ class="globalNav-btn"
+ type="button"
+ data-bs-toggle="dropdown"
+ aria-expanded="false"
+ title="@StringLocalizer["Server Settings"]"
+ data-bs-trigger="hover"
+ data-bs-placement="bottom"
+ data-global-nav-tooltip="1">
+ <vc:icon symbol="nav-server-settings" />
+ </button>
+ <ul id="globalNavServerMenu" class="dropdown-menu dropdown-menu-end py-0">
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Policies)" asp-area="" asp-controller="UIServer" asp-action="Policies" text-translate="true">Policies</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Users)" asp-controller="UIServer" asp-action="ListUsers" text-translate="true">Users</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Roles)" asp-controller="UIServer" asp-action="ListRoles" text-translate="true">Roles</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Emails</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Services)" asp-controller="UIServer" asp-action="Services" text-translate="true">Services</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Branding)" asp-controller="UIServer" asp-action="Branding" text-translate="true">Branding</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Translations)" asp-area="@TranslationsPlugin.Area" asp-controller="UITranslation" asp-action="ListDictionaries" text-translate="true">Translations</a>
+ </li>
+ @if (Model.DockerDeployment)
+ {
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Maintenance)" asp-controller="UIServer" asp-action="Maintenance" text-translate="true">Maintenance</a>
+ </li>
+ }
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Logs)" asp-controller="UIServer" asp-action="LogsView" text-translate="true">Logs</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ServerNavPages.Files)" asp-controller="UIServer" asp-action="Files" text-translate="true">Files</a>
+ </li>
+ <vc:ui-extension-point location="server-nav" model="@Model.MainNav" />
+ </ul>
+ </div>
+
+ <div class="dropdown">
+ <button id="menu-item-Account"
+ class="globalNav-btn menu-item nav-link"
+ type="button"
+ data-bs-toggle="dropdown"
+ data-bs-auto-close="outside"
+ aria-expanded="false"
+ title="@StringLocalizer["Account"]"
+ data-bs-trigger="hover"
+ data-bs-placement="bottom"
+ data-global-nav-tooltip="1">
+ @if (!string.IsNullOrEmpty(Model.UserImageUrl))
+ {
+ <img src="@Model.UserImageUrl" alt="Profile picture" class="profile-picture" style="--profile-picture-size:1.5rem" />
+ }
+ else
+ {
+ <vc:icon symbol="nav-account" />
+ }
+ </button>
+ <ul id="globalNavAccountMenu" class="dropdown-menu dropdown-menu-end py-0" aria-labelledby="menu-item-Account">
+ <li class="p-3 border-bottom d-flex align-items-center gap-2">
+ @if (!string.IsNullOrEmpty(Model.UserImageUrl))
+ {
+ <img src="@Model.UserImageUrl" alt="Profile picture" class="profile-picture" />
+ }
+ <div>
+ <strong class="d-block text-truncate" style="max-width:@(string.IsNullOrEmpty(Model.UserImageUrl) ? "195px" : "160px")" title="@accountDisplayName">@accountDisplayName</strong>
+ @if (User.IsInRole(Roles.ServerAdmin))
+ {
+ <div class="text-secondary" text-translate="true">Administrator</div>
+ }
+ </div>
+ </li>
+ @if (!Theme.CustomTheme)
+ {
+ <li class="py-1 px-3">
+ <vc:theme-switch css-class="w-100 pt-2" />
+ </li>
+ }
+ <li class="py-1 px-3">
+ <div class="d-flex align-items-center justify-content-between gap-3 nav-link">
+ <label for="HideSensitiveInfo" class="fw-semibold mb-0 cursor-pointer" text-translate="true">Hide Sensitive Info</label>
+ <input id="HideSensitiveInfo" name="HideSensitiveInfo" type="checkbox" class="btcpay-toggle" />
+ </div>
+ <script>
+ document.getElementById('HideSensitiveInfo').checked = window.localStorage.getItem('btcpay-hide-sensitive-info') === 'true';
+ </script>
+ </li>
+ <li class="border-top py-1 px-3">
+ <a asp-area="" asp-controller="UIManage" asp-action="Index" class="nav-link" id="Nav-ManageAccount">
+ <span text-translate="true">Manage Account</span>
+ </a>
+ </li>
+ <li class="border-top py-1 px-3">
+ <a layout-menu-item="@nameof(ManageNavPages.ChangePassword)" asp-controller="UIManage" asp-action="ChangePassword" text-translate="true">Password</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ManageNavPages.TwoFactorAuthentication)" asp-controller="UIManage" asp-action="TwoFactorAuthentication" text-translate="true">Two-Factor Authentication</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ManageNavPages.APIKeys)" asp-controller="UIManage" asp-action="APIKeys" text-translate="true">API Keys</a>
+ </li>
+ <li class="py-1 px-3">
+ <a layout-menu-item="@nameof(ManageNavPages.Notifications)" asp-controller="UIManage" asp-action="NotificationSettings" text-translate="true">Notifications</a>
+ </li>
+ <vc:ui-extension-point location="user-nav" model="@Model.MainNav" />
+ @if (!string.IsNullOrWhiteSpace(Model.ContactUrl))
+ {
+ <li class="py-1 px-3 border-top">
+ <a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs">
+ <span text-translate="true">Contact Us</span>
+ </a>
+ </li>
+ }
+ <li class="border-top py-1 px-3">
+ <a asp-area="" asp-controller="UIAccount" asp-action="Logout" class="nav-link text-danger" id="Nav-Logout">
+ <span text-translate="true">Logout</span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+</div>
diff --git a/BTCPayServer/Components/GlobalNav/GlobalNav.cs b/BTCPayServer/Components/GlobalNav/GlobalNav.cs
new file mode 100644
index 0000000..d183ef4
--- /dev/null
+++ b/BTCPayServer/Components/GlobalNav/GlobalNav.cs
@@ -0,0 +1,48 @@
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Components.MainNav;
+using BTCPayServer.Configuration;
+using BTCPayServer.Data;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Components.GlobalNav
+{
+ public class GlobalNav(
+ UserManager<ApplicationUser> userManager,
+ UriResolver uriResolver,
+ SettingsRepository settingsRepository,
+ BTCPayServerOptions btcPayServerOptions)
+ : ViewComponent
+ {
+ public async Task<IViewComponentResult> InvokeAsync()
+ {
+ var store = ViewContext.HttpContext.GetStoreDataOrNull();
+ var serverSettings = await settingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
+ var vm = new GlobalNavViewModel
+ {
+ ContactUrl = serverSettings.ContactUrl,
+ DockerDeployment = btcPayServerOptions.DockerDeployment,
+ CurrentStoreId = store?.Id,
+ MainNav = new MainNavViewModel
+ {
+ Store = store
+ }
+ };
+
+ var user = await userManager.GetUserAsync(HttpContext.User);
+ if (user != null)
+ {
+ var blob = user.GetBlob();
+ var imageUrl = blob?.ImageUrl;
+ vm.UserName = blob?.Name;
+ vm.UserImageUrl = string.IsNullOrEmpty(imageUrl)
+ ? null
+ : await uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(imageUrl));
+ }
+
+ return View(vm);
+ }
+ }
+}
diff --git a/BTCPayServer/Components/GlobalNav/GlobalNavViewModel.cs b/BTCPayServer/Components/GlobalNav/GlobalNavViewModel.cs
new file mode 100644
index 0000000..c76a145
--- /dev/null
+++ b/BTCPayServer/Components/GlobalNav/GlobalNavViewModel.cs
@@ -0,0 +1,14 @@
+using BTCPayServer.Components.MainNav;
+
+namespace BTCPayServer.Components.GlobalNav
+{
+ public class GlobalNavViewModel
+ {
+ public string UserName { get; set; }
+ public string UserImageUrl { get; set; }
+ public string ContactUrl { get; set; }
+ public bool DockerDeployment { get; set; }
+ public string CurrentStoreId { get; set; }
+ public MainNavViewModel MainNav { get; set; }
+ }
+}
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index c0d0089..54ee3ed 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -1,21 +1,14 @@
-@using BTCPayServer.Views.Server
@using BTCPayServer.Views.Stores
-@using BTCPayServer.Views.Manage
@using BTCPayServer.Views.Wallets
@using BTCPayServer.Client
@using BTCPayServer.Plugins
@using BTCPayServer.Plugins.Wallets
@using BTCPayServer.Services
@using BTCPayServer.Views.Apps
-@using BTCPayServer.Configuration
@using BTCPayServer.Plugins.Emails
-@using BTCPayServer.Plugins.Translations
-@inject BTCPayServerOptions BtcPayServerOptions
@inject BTCPayServerEnvironment Env
@inject SignInManager<ApplicationUser> SignInManager
@inject PoliciesSettings PoliciesSettings
-@inject ThemeSettings Theme
-@inject PluginService PluginService
@inject PrettyNameProvider PrettyName
@model BTCPayServer.Components.MainNav.MainNavViewModel
@@ -32,19 +25,9 @@
using var __ = this.Context.SwitchNavRendering();
}
-
-<div id="mainMenuHead">
- <button id="mainMenuToggle" class="mainMenuButton" type="button" data-bs-toggle="offcanvas" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
- <span>Menu</span>
- </button>
- <vc:store-selector />
- @if (SignInManager.IsSignedIn(User))
- {
- <component type="typeof(BTCPayServer.Blazor.NotificationsDropDown)" render-mode="ServerPrerendered" />
- }
-</div>
<nav id="mainNav" class="d-flex flex-column justify-content-between">
<div class="accordion px-3 px-lg-4">
+ <vc:store-selector />
@if (SignInManager.IsSignedIn(User))
{
@if (Model.Store != null)
@@ -254,19 +237,6 @@
}
</ul>
<ul class="navbar-nav">
- <li class="nav-item" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Plugins)" asp-area="" asp-controller="UIServer" asp-action="ListPlugins">
- @if (PluginService.GetDisabledPlugins().Any())
- {
- <span class="me-2 btcpay-status btcpay-status--disabled"></span>
- }
- else
- {
- <vc:icon symbol="nav-plugins-manage" />
- }
- <span text-translate="true">Manage Plugins</span>
- </a>
- </li>
@if (Model.Store != null && Model.ArchivedAppsCount > 0)
{
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyStoreSettings">
@@ -279,8 +249,41 @@
</div>
</div>
</div>
+ <div id="MainNavTopRightHint" class="mainNav-top-right-hint py-2 px-3 mb-3 d-flex align-items-start gap-2" role="status">
+ @if (User.IsInRole(Roles.ServerAdmin))
+ {
+ <div class="small" text-translate="true">Server and account settings moved to the top-right menu, just look up!</div>
+ }
+ else
+ {
+ <div class="small" text-translate="true">Account settings and navigation moved to the top-right menu, just look up!</div>
+ }
+ <button type="button" class="mainNav-top-right-hint-close ms-auto" data-dismiss-main-nav-hint aria-label="@StringLocalizer["Dismiss"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
<script>
(() => {
+ const topRightHintKey = 'btcpay-main-nav-top-right-hint-dismissed'
+ const topRightHint = document.getElementById('MainNavTopRightHint')
+ if (topRightHint) {
+ const dismissHint = () => {
+ try {
+ window.localStorage.setItem(topRightHintKey, 'true')
+ } catch {}
+ topRightHint.remove()
+ }
+ try {
+ if (window.localStorage.getItem(topRightHintKey) === 'true') {
+ topRightHint.remove()
+ }
+ } catch {}
+ const dismissButton = topRightHint.querySelector('[data-dismiss-main-nav-hint]')
+ dismissButton?.addEventListener('click', event => {
+ event.preventDefault()
+ dismissHint()
+ })
+ }
// apply collapse settings
const navCollapsed = window.localStorage.getItem('btcpay-nav-collapsed')
const collapsed = navCollapsed ? JSON.parse(navCollapsed) : []
@@ -294,9 +297,10 @@
}
})
// hide empty plugins drawer
- const pluginsItem = document.getElementById('Nav-Plugins').closest('.accordion-item')
- const pluginsContent = pluginsItem.querySelector('.navbar-nav').innerHTML.trim()
- if (pluginsContent === '') {
+ const pluginsNav = document.getElementById('Nav-Plugins')
+ const pluginsItem = pluginsNav?.closest('.accordion-item')
+ const pluginsContent = pluginsItem?.querySelector('.navbar-nav')?.innerHTML.trim()
+ if (pluginsItem && pluginsContent === '') {
pluginsItem.setAttribute('hidden', true)
}
})()
@@ -317,132 +321,6 @@
</ul>
}
</div>
- @if (SignInManager.IsSignedIn(User))
- {
- <ul id="mainNavSettings" class="navbar-nav border-top p-3 px-lg-4">
- <li class="nav-item" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Policies)" asp-area="" asp-controller="UIServer" asp-action="Policies">
- <vc:icon symbol="nav-server-settings"/>
- <span text-translate="true">Server Settings</span>
- </a>
- </li>
- @if (ViewData.IsCategory(WellKnownCategories.Server))
- {
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Users)" asp-controller="UIServer" asp-action="ListUsers" text-translate="true">Users</a>
- </li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Roles)" asp-controller="UIServer" asp-action="ListRoles" text-translate="true">Roles</a>
- </li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="Server-@nameof(ServerNavPages.Emails)" asp-area="@EmailsPlugin.Area" asp-controller="UIServerEmail" asp-action="ServerEmailSettings" text-translate="true">Emails</a>
- </li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Services)" asp-controller="UIServer" asp-action="Services" text-translate="true">Services</a>
- </li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Branding)" asp-controller="UIServer" asp-action="Branding" text-translate="true">Branding</a>
- </li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Translations)" asp-area="@TranslationsPlugin.Area" asp-controller="UITranslation" asp-action="ListDictionaries" text-translate="true">Translations</a>
- </li>
- @if (BtcPayServerOptions.DockerDeployment)
- {
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Maintenance)" asp-controller="UIServer" asp-action="Maintenance" text-translate="true">Maintenance</a>
- </li>
- }
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Logs)" asp-controller="UIServer" asp-action="LogsView" text-translate="true">Logs</a>
- </li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
- <a layout-menu-item="@nameof(ServerNavPages.Files)" asp-controller="UIServer" asp-action="Files" text-translate="true">Files</a>
- </li>
-
- <vc:ui-extension-point location="server-nav" model="@Model"/>
- }
- <li class="nav-item dropup">
- <a layout-menu-item="Account" role="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
- <vc:icon symbol="nav-account"/>
- <span text-translate="true">Account</span>
- </a>
- <ul class="dropdown-menu py-0 w-100" aria-labelledby="Nav-Account">
- <li class="p-3 border-bottom d-flex align-items-center gap-2">
- @if (!string.IsNullOrEmpty(Model.UserImageUrl))
- {
- <img src="@Model.UserImageUrl" alt="Profile picture" class="profile-picture"/>
- }
- <div>
- <strong class="d-block text-truncate" style="max-width:@(string.IsNullOrEmpty(Model.UserImageUrl) ? "195px" : "160px")">
- @if (string.IsNullOrEmpty(Model.UserName))
- {
- @(User.Identity?.Name)
- }
- else
- {
- @($"{Model.UserName} ({User.Identity?.Name})")
- }
- </strong>
- @if (User.IsInRole(Roles.ServerAdmin))
- {
- <div class="text-secondary" text-translate="true">Administrator</div>
- }
- </div>
- </li>
- @if (!Theme.CustomTheme)
- {
- <li class="py-1 px-3">
- <vc:theme-switch css-class="w-100 pt-2"/>
- </li>
- }
- <li class="py-1 px-3">
- <label class="d-flex align-items-center justify-content-between gap-3 nav-link">
- <span class="fw-semibold" text-translate="true">Hide Sensitive Info</span>
- <input id="HideSensitiveInfo" name="HideSensitiveInfo" type="checkbox" class="btcpay-toggle" />
- </label>
- <script>
- document.getElementById('HideSensitiveInfo').checked = window.localStorage.getItem('btcpay-hide-sensitive-info') === 'true';
- </script>
- </li>
- <li class="border-top py-1 px-3">
- <a asp-area="" asp-controller="UIManage" asp-action="Index" class="nav-link" id="Nav-ManageAccount">
- <span text-translate="true">Manage Account</span>
- </a>
- </li>
- <li class="border-top py-1 px-3">
- <a asp-area="" asp-controller="UIAccount" asp-action="Logout" class="nav-link text-danger" id="Nav-Logout">
- <span text-translate="true">Logout</span>
- </a>
- </li>
- </ul>
- </li>
- @if (ViewData.IsCategory(nameof(ManageNavPages)))
- {
- <li class="nav-item nav-item-sub">
- <a layout-menu-item="@nameof(ManageNavPages.ChangePassword)" asp-controller="UIManage" asp-action="ChangePassword" text-translate="true">Password</a>
- </li>
- <li class="nav-item nav-item-sub">
- <a layout-menu-item="@nameof(ManageNavPages.TwoFactorAuthentication)" asp-controller="UIManage" asp-action="TwoFactorAuthentication" text-translate="true">Two-Factor Authentication</a>
- </li>
- <li class="nav-item nav-item-sub">
- <a layout-menu-item="@nameof(ManageNavPages.APIKeys)" asp-controller="UIManage" asp-action="APIKeys" text-translate="true">API Keys</a>
- </li>
- <li class="nav-item nav-item-sub">
- <a layout-menu-item="@nameof(ManageNavPages.Notifications)" asp-controller="UIManage" asp-action="NotificationSettings" text-translate="true">Notifications</a>
- </li>
- <vc:ui-extension-point location="user-nav" model="@Model" />
- }
- @if (!string.IsNullOrWhiteSpace(Model.ContactUrl))
- {
- <li class="nav-item">
- <a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs">
- <vc:icon symbol="nav-contact"/>
- <span text-translate="true">Contact Us</span>
- </a>
- </li>
- }
- </ul>
- }
</nav>
<script>
(function () {
diff --git a/BTCPayServer/Components/MainNav/MainNav.cs b/BTCPayServer/Components/MainNav/MainNav.cs
index 3d3271d..ec8e2c5 100644
--- a/BTCPayServer/Components/MainNav/MainNav.cs
+++ b/BTCPayServer/Components/MainNav/MainNav.cs
@@ -21,11 +21,8 @@ namespace BTCPayServer.Components.MainNav
public class MainNav(
AppService appService,
UIStoresController storesController,
- UserManager<ApplicationUser> userManager,
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
- SettingsRepository settingsRepository,
IMemoryCache cache,
- UriResolver uriResolver,
PoliciesSettings policiesSettings)
: ViewComponent
{
@@ -34,12 +31,9 @@ namespace BTCPayServer.Components.MainNav
public async Task<IViewComponentResult> InvokeAsync()
{
var navStore = HttpContext.GetNavStoreData();
-
- var serverSettings = await settingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
var vm = new MainNavViewModel
{
- Store = navStore,
- ContactUrl = serverSettings.ContactUrl
+ Store = navStore
};
if (navStore != null)
{
@@ -97,17 +91,6 @@ namespace BTCPayServer.Components.MainNav
vm.ArchivedAppsCount = apps.Count(a => a.Archived);
}
-
- var user = await userManager.GetUserAsync(HttpContext.User);
- if (user != null)
- {
- var blob = user.GetBlob();
- vm.UserName = blob?.Name;
- vm.UserImageUrl = string.IsNullOrEmpty(blob?.ImageUrl)
- ? null
- : await uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl));
- }
-
return View(vm);
}
}
diff --git a/BTCPayServer/Components/MainNav/MainNavViewModel.cs b/BTCPayServer/Components/MainNav/MainNavViewModel.cs
index 830e745..434a42c 100644
--- a/BTCPayServer/Components/MainNav/MainNavViewModel.cs
+++ b/BTCPayServer/Components/MainNav/MainNavViewModel.cs
@@ -11,9 +11,6 @@ namespace BTCPayServer.Components.MainNav
public List<StoreLightningNode> LightningNodes { get; set; }
public List<StoreApp> Apps { get; set; }
public int ArchivedAppsCount { get; set; }
- public string ContactUrl { get; set; }
- public string UserName { get; set; }
- public string UserImageUrl { get; set; }
}
public class StoreApp
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index 903ec5d..a6716e0 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -1073,7 +1073,7 @@ namespace BTCPayServer.Controllers
model.Search = fs;
model.SearchText = fs.TextCombined;
- var apps = await _appService.GetAllApps(User.GetIdOrNull(), false, storeId);
+ var apps = await _appService.GetAllApps(User.GetIdOrNull(), false, storeId);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Take = model.Count;
@@ -1114,7 +1114,11 @@ namespace BTCPayServer.Controllers
private InvoiceQuery GetInvoiceQuery(SearchString fs, ListAppsViewModel.ListAppViewModel[] apps, int timezoneOffset = 0)
{
- var textSearch = fs.TextSearch;
+ var query = new InvoiceQuery()
+ {
+ UserId = GetUserIdForInvoiceQuery()
+ };
+ query.FillFromSearchText(fs, timezoneOffset);
if (fs.GetFilterArray("appid") is { } appIds)
{
var appsById = apps.ToDictionary(a => a.Id);
@@ -1122,22 +1126,10 @@ namespace BTCPayServer.Controllers
.Select(a => AppService.GetAppSearchTerm(a!.AppType, a.Id))
.ToList();
searchTexts.Add(fs.TextSearch);
- textSearch = string.Join(' ', searchTexts.Where(t => !string.IsNullOrEmpty(t)).ToList());
+ var textSearch = string.Join(' ', searchTexts.Where(t => !string.IsNullOrEmpty(t)).ToList());
+ query.TextSearch = textSearch;
}
- return new InvoiceQuery
- {
- TextSearch = textSearch,
- UserId = GetUserIdForInvoiceQuery(),
- Unusual = fs.GetFilterBool("unusual"),
- IncludeArchived = fs.GetFilterBool("includearchived") ?? false,
- Status = fs.GetFilterArray("status"),
- ExceptionStatus = fs.GetFilterArray("exceptionstatus"),
- StoreId = fs.GetFilterArray("storeid"),
- ItemCode = fs.GetFilterArray("itemcode"),
- OrderId = fs.GetFilterArray("orderid"),
- StartDate = fs.GetFilterDate("startdate", timezoneOffset),
- EndDate = fs.GetFilterDate("enddate", timezoneOffset)
- };
+ return query;
}
[HttpGet("/stores/{storeId}/invoices/create")]
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 032d95e..4ade338 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -69,6 +69,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Payouts;
+using BTCPayServer.Plugins.Bitcoin;
using ExchangeSharp;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
@@ -389,6 +390,7 @@ namespace BTCPayServer.Hosting
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
+ services.AddSearchResultItemProvider<ReportingSearchResultProvider>();
services.AddReportProvider<PaymentsReportProvider>();
services.AddReportProvider<OnChainWalletReportProvider>();
services.AddReportProvider<ProductsReportProvider>();
diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs
index 0b49825..4dc25d5 100644
--- a/BTCPayServer/Hosting/Startup.cs
+++ b/BTCPayServer/Hosting/Startup.cs
@@ -178,13 +178,16 @@ namespace BTCPayServer.Hosting
// /Components/{View Component Name}/{View Name}.cshtml
o.ViewLocationFormats.Add("/{0}.cshtml");
o.PageViewLocationFormats.Add("/{0}.cshtml");
+ o.AreaViewLocationFormats.Add("/{0}.cshtml");
// Allows the use of Area for plugins
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{1}/{0}.cshtml");
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/{0}.cshtml");
o.AreaViewLocationFormats.Add("/Plugins/{2}/Views/Shared/{0}.cshtml");
- o.AreaViewLocationFormats.Add("/{0}.cshtml");
+ o.AreaViewLocationFormats.Add("/Plugins/{2}/Pages/{1}/{0}.cshtml");
+ o.AreaViewLocationFormats.Add("/Plugins/{2}/Pages/{0}.cshtml");
+ o.AreaViewLocationFormats.Add("/Plugins/{2}/Pages/Shared/{0}.cshtml");
})
.AddNewtonsoftJson()
.AddPlugins(services, Configuration, LoggerFactory, bootstrapServiceProvider)
diff --git a/BTCPayServer/Plugins/Bitpay/BitpayPlugin.cs b/BTCPayServer/Plugins/Bitpay/BitpayPlugin.cs
index ecacf79..4cd5b8e 100644
--- a/BTCPayServer/Plugins/Bitpay/BitpayPlugin.cs
+++ b/BTCPayServer/Plugins/Bitpay/BitpayPlugin.cs
@@ -1,8 +1,10 @@
#nullable enable
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
using BTCPayServer.Plugins.Bitpay.Controllers;
using BTCPayServer.Plugins.Bitpay.Security;
+using BTCPayServer.Plugins.GlobalSearch;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
@@ -36,5 +38,16 @@ public class BitpayPlugin : BaseBTCPayServerPlugin
services.AddAuthentication()
.AddScheme<BitpayAuthenticationOptions, BitpayAuthenticationHandler>(AuthenticationSchemes.Bitpay, o => { });
services.AddUIExtension("store-category-nav", "/Plugins/Bitpay/Views/NavExtension.cshtml");
+
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewStoreSettings,
+ Title = "View the access tokens (for legacy API access)",
+ Action = nameof(UIStoresTokenController.ListTokens),
+ Controller = "UIStoresToken",
+ Values = (ctx) => new { storeId = ctx.Store!.Id, area = Area },
+ Category = "Store",
+ Keywords = new[] { "Tokens" }
+ });
}
}
diff --git a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
index 06ba643..9847edc 100644
--- a/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
+++ b/BTCPayServer/Plugins/Emails/EmailsPlugin.cs
@@ -1,8 +1,11 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
using BTCPayServer.Data;
+using BTCPayServer.Plugins.Emails.Controllers;
using BTCPayServer.Plugins.Emails.HostedServices;
using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Plugins.GlobalSearch;
using BTCPayServer.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -45,8 +48,55 @@ public class EmailsPlugin : BaseBTCPayServerPlugin
services.AddDefaultTranslations(StoreTransformer.TranslatedStrings);
+ ConfigureEmailSearch(services);
+
RegisterServerEmailTriggers(services);
}
+
+ private static void ConfigureEmailSearch(IServiceCollection services)
+ {
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewStoreSettings,
+ Title = "View store's email rules",
+ Action = nameof(UIStoreEmailRulesController.StoreEmailRulesList),
+ Controller = "UIStoreEmailRules",
+ Values = ctx => new { area = Area, storeId = ctx.Store!.Id },
+ Category = "Store",
+ Keywords = ["Email", "Rules"]
+ });
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanModifyServerSettings,
+ Title = "View server's email rules",
+ Action = nameof(UIServerEmailRulesController.ServerEmailRulesList),
+ Controller = "UIServerEmailRules",
+ Values = ctx => new { area = Area },
+ Category = "Server",
+ Keywords = ["Email", "Rules"]
+ });
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewStoreSettings,
+ Title = "Configure store's email settings",
+ Action = nameof(UIStoresEmailController.StoreEmailSettings),
+ Controller = "UIStoresEmail",
+ Values = ctx => new { area = Area, storeId = ctx.Store!.Id },
+ Category = "Store",
+ Keywords = ["Email", "Settings"]
+ });
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanModifyServerSettings,
+ Title = "Configure server's email settings",
+ Action = nameof(UIServerEmailController.ServerEmailSettings),
+ Controller = "UIServerEmail",
+ Values = ctx => new { area = Area },
+ Category = "Server",
+ Keywords = ["Email", "Settings"]
+ });
+ }
+
private static string BODY_STYLE = "font-family: Open Sans, Helvetica Neue,Arial,sans-serif; font-color: #292929;";
private static string HEADER_HTML = "<h1 style='font-size:1.2rem'>{Server.Name}</h1><br/>";
private static string BUTTON_HTML = "<a href='{button_link}' type='submit' style='min-width: 2em;min-height: 20px;text-decoration-line: none;cursor: pointer;display: inline-block;font-weight: 400;color: #fff;text-align: center;vertical-align: middle;user-select: none;background-color: #51b13e;border-color: #51b13e;border: 1px solid transparent;padding: 0.375rem 0.75rem;font-size: 1rem;line-height: 1.5;border-radius: 0.25rem;transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;'>{button_description}</a>";
diff --git a/BTCPayServer/Plugins/Forms/FormsPlugin.cs b/BTCPayServer/Plugins/Forms/FormsPlugin.cs
index 6da30a7..877c676 100644
--- a/BTCPayServer/Plugins/Forms/FormsPlugin.cs
+++ b/BTCPayServer/Plugins/Forms/FormsPlugin.cs
@@ -1,5 +1,8 @@
using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
using BTCPayServer.Forms;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.Webhooks.Controllers;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Plugins.Forms;
@@ -21,5 +24,16 @@ public class FormsPlugin : BaseBTCPayServerPlugin
services.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
services.AddSingleton<IFormComponentProvider, HtmlSelectFormProvider>();
services.AddSingleton<IFormComponentProvider, FieldValueMirror>();
+
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewStoreSettings,
+ Title = "Configure customer's forms",
+ Action = nameof(UIFormsController.FormsList),
+ Controller = "UIForms",
+ Values = ctx => new { area = Area, storeId = ctx.Store!.Id },
+ Category = "Store",
+ Keywords = ["Forms", "Configure"]
+ });
}
}
diff --git a/BTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.cs b/BTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.cs
new file mode 100644
index 0000000..4fc484b
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/Controllers/UISearchController.cs
@@ -0,0 +1,41 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Services.Stores;
+
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Net.Http.Headers;
+
+namespace BTCPayServer.Plugins.GlobalSearch;
+
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Area(GlobalSearchPlugin.Area)]
+public class UISearchController(
+ StoreRepository storeRepository,
+ SearchResultItemProviders searchResultItemProviders)
+ : Controller
+{
+ [HttpGet("~/search/global")]
+ [DisableCors]
+ public async Task<IActionResult> Global(
+ string q = null,
+ string storeId = null,
+ int? take = null,
+ string hash = null,
+ CancellationToken cancellationToken = default)
+ {
+ var store = storeId is null ? null : await storeRepository.FindStore(storeId, User);
+ var vm = await searchResultItemProviders.GetViewModel(this.User, store, this.Url, q, take, cancellationToken);
+ if (take is not null)
+ vm.Items = vm.Items.Take(take.Value).ToList();
+ if (hash != null && q is null)
+ {
+ const int durationInSeconds = 60 * 60 * 24 * 365;
+ HttpContext.Response.Headers[HeaderNames.CacheControl] = "private,max-age=" + durationInSeconds;
+ }
+ return Json(vm.Items);
+ }
+}
diff --git a/BTCPayServer/Plugins/GlobalSearch/DefaultSearchResultProvider.cs b/BTCPayServer/Plugins/GlobalSearch/DefaultSearchResultProvider.cs
new file mode 100644
index 0000000..38c2901
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/DefaultSearchResultProvider.cs
@@ -0,0 +1,121 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Controllers;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using BTCPayServer.Plugins.Impersonation;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Plugins.GlobalSearch;
+
+public class DefaultSearchResultProvider : ISearchResultItemProvider
+{
+ public async Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken)
+ {
+ if (context.UserQuery is not null)
+ return;
+
+ var canModifyServer = await context.IsAuthorized(Policies.CanModifyServerSettings);
+ var canViewProfile = await context.IsAuthorized(Policies.CanViewProfile);
+ var canViewNotifications = await context.IsAuthorized(Policies.CanViewNotificationsForUser);
+ var results = context.ItemResults;
+ var store = context.Store;
+ if (store != null)
+ {
+ var canViewStoreSettings = await context.IsAuthorized(Policies.CanViewStoreSettings);
+ var canModifyStoreSettings = await context.IsAuthorized(Policies.CanModifyStoreSettings);
+
+ var canViewPaymentRequests = await context.IsAuthorized(Policies.CanViewPaymentRequests);
+ var canViewPullPayments = await context.IsAuthorized(Policies.CanViewPullPayments);
+ var canViewPayouts = await context.IsAuthorized(Policies.CanViewPayouts);
+
+ if (canModifyStoreSettings)
+ {
+ AddPage(results, "Dashboard", context.Url.Action(nameof(UIStoresController.Dashboard), "UIStores", new { storeId = store.Id }), "Store");
+ }
+
+ if (canViewStoreSettings)
+ {
+ AddPage(results, "Go to the store's settings", context.Url.Action(nameof(UIStoresController.GeneralSettings), "UIStores", new { storeId = store.Id }), "Store",
+ ["Settings", "Branding"]);
+ AddPage(results, "Configure exchange rates", context.Url.Action(nameof(UIStoresController.Rates), "UIStores", new { storeId = store.Id }), "Store", ["Exchange", "Configure"]);
+ AddPage(results, "Configure the appearance of the checkout", context.Url.Action(nameof(UIStoresController.CheckoutAppearance), "UIStores", new { storeId = store.Id }),
+ "Store", ["Checkout", "Appearance", "Configure"]);
+ AddPage(results, "View store users", context.Url.Action(nameof(UIStoresController.StoreUsers), "UIStores", new { storeId = store.Id }), "Store", ["Users", "View"]);
+ AddPage(results, "View store roles", context.Url.Action(nameof(UIStoresController.ListRoles), "UIStores", new { storeId = store.Id }), "Store", ["Roles", "View"]);
+ AddPage(results, "Configure the payout processors", context.Url.Action("ConfigureStorePayoutProcessors", "UIPayoutProcessors", new { storeId = store.Id }), "Store",
+ ["Payout", "Processors", "Configure"]);
+ }
+
+ if (canViewPaymentRequests)
+ {
+ AddPage(results, "View the payment requests",
+ context.Url.Action(nameof(UIPaymentRequestController.GetPaymentRequests), "UIPaymentRequest", new { storeId = store.Id }), "Payments",
+ ["Payment", "Requests", "View"]);
+ }
+
+ if (canViewPullPayments)
+ {
+ AddPage(results, "View the pull payments", context.Url.Action("PullPayments", "UIStorePullPayments", new { storeId = store.Id }), "Payments", ["Pull Payments", "Pull", "View"]);
+ }
+
+ if (canViewPayouts)
+ {
+ AddPage(results, "View the payouts", context.Url.Action("Payouts", "UIStorePullPayments", new { storeId = store.Id }), "Payments", ["Payouts", "View"]);
+ }
+ }
+
+ if (canModifyServer)
+ {
+ AddPage(results, "Configure the server settings", context.Url.Action(nameof(UIServerController.Policies), "UIServer"), "Server", ["Server", "Settings", "Policies", "Configure"]);
+ AddPage(results, "View server's registered users", context.Url.Action(nameof(UIServerController.ListUsers), "UIServer"), "Server", ["Server", "Settings", "Users", "View"]);
+ AddPage(results, "View predefined store roles", context.Url.Action(nameof(UIServerController.ListRoles), "UIServer"), "Server", ["Server", "Settings", "Roles", "View"]);
+ AddPage(results, "View access to external services", context.Url.Action(nameof(UIServerController.Services), "UIServer"), "Server", ["Server", "Settings", "Services", "View"]);
+ AddPage(results, "Configure the branding appearance of the server", context.Url.Action(nameof(UIServerController.Branding), "UIServer"), "Server", ["Server", "Settings", "Branding", "Configure"]);
+ AddPage(results, "Go to the maintenance page", context.Url.Action(nameof(UIServerController.Maintenance), "UIServer"), "Server", ["Server", "Settings", "Maintenance"]);
+ AddPage(results, "Update the server", context.Url.Action(nameof(UIServerController.Maintenance), "UIServer"), "Server", ["Server", "Settings", "Maintenance"]);
+ AddPage(results, "View the logs", context.Url.Action(nameof(UIServerController.LogsView), "UIServer"), "Server", ["Server", "Settings", "Logs", "View"]);
+ AddPage(results, "Configure a file provider", context.Url.Action(nameof(UIServerController.Files), "UIServer"), "Server", ["Server", "Settings", "Files", "Storage", "Configure"]);
+
+ var pluginsUrl = context.Url.Action(nameof(UIServerController.ListPlugins), "UIServer");
+ AddPage(results, "Install, update, and configure plugins", pluginsUrl, "Server", ["Plugins", "Configure", "Update", "Install"]);
+ }
+
+ if (canViewProfile)
+ {
+ AddPage(results, "Manage your account", context.Url.Action(nameof(UIManageController.Index), "UIManage"), "Account", ["Profile", "Account", "Manage"]);
+ AddPage(results, "Change your password", context.Url.Action(nameof(UIManageController.ChangePassword), "UIManage"), "Account", ["Password", "Change"]);
+ AddPage(results, "Configure Two-Factor Authentication", context.Url.Action(nameof(UIManageController.TwoFactorAuthentication), "UIManage"), "Account",
+ ["2FA Security", "Two-Factor", "Authentication"]);
+ AddPage(results, "Manage API Keys", context.Url.Action(nameof(UIManageController.APIKeys), "UIManage"), "Account", ["API", "Keys"]);
+ AddPage(results, "Manage the notification settings", context.Url.Action(nameof(UIManageController.NotificationSettings), "UIManage"), "Account", ["Notifications", "Manage"]);
+ }
+
+ if (canViewNotifications)
+ {
+ AddPage(results, "View your notifications", context.Url.Action(nameof(UINotificationsController.Index), "UINotifications"), "Account", ["Notifications", "View"]);
+ }
+
+ AddPage(results, "Go to the dashboard", context.Url.Action(nameof(UIHomeController.Index), "UIHome"), "General", ["Dashboard", "Overview"]);
+ AddPage(results, "View all the stores", context.Url.Action(nameof(UIUserStoresController.ListStores), "UIUserStores"), "Store", ["Stores", "List", "View"]);
+ AddPage(results, "Browse the API documentation", context.Url.Action(nameof(UIHomeController.SwaggerDocs), "UIHome"), "General", ["Documentation", "API", "Docs", "Browse"]);
+ }
+
+
+ private void AddPage(List<ResultItemViewModel> results, string title, string? url, string category, string[]? keywords = null, string? subtitle = null)
+ {
+ if (url is null)
+ throw new ArgumentNullException(nameof(url), "URL cannot be null");
+ results.Add(new ResultItemViewModel
+ {
+ Category = category,
+ Title = title,
+ Url = url,
+ Keywords = keywords ?? [],
+ SubTitle = subtitle
+ });
+ }
+}
diff --git a/BTCPayServer/Plugins/GlobalSearch/GlobalSearchExtensions.cs b/BTCPayServer/Plugins/GlobalSearch/GlobalSearchExtensions.cs
new file mode 100644
index 0000000..9e6ddc2
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/GlobalSearchExtensions.cs
@@ -0,0 +1,26 @@
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer;
+
+public static class GlobalSearchExtensions
+{
+ public static IServiceCollection AddSearchResultItemProvider<T>(this IServiceCollection services) where T : class, ISearchResultItemProvider
+ {
+ services.AddSingleton<ISearchResultItemProvider, T>();
+ return services;
+ }
+ public static IServiceCollection AddStaticSearch(this IServiceCollection services, ResultItemViewModel vm)
+ {
+ services.AddSingleton(vm);
+ return services;
+ }
+
+ public static IServiceCollection AddStaticSearch(this IServiceCollection services, ActionResultItemViewModel actionResultItem)
+ {
+ services.AddSingleton(actionResultItem);
+ return services;
+ }
+}
+
diff --git a/BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs b/BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs
new file mode 100644
index 0000000..6e6abeb
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs
@@ -0,0 +1,21 @@
+using BTCPayServer.Abstractions.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.GlobalSearch;
+
+public class GlobalSearchPlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "GlobalSearch";
+ public override string Identifier => "BTCPayServer.Plugins.GlobalSearch";
+ public override string Name => "GlobalSearch";
+ public override string Description => "Add global search feature to your server.";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddUIExtension("global-nav", "/Plugins/GlobalSearch/Views/NavExtension.cshtml");
+ services.AddSearchResultItemProvider<StaticSearchResultProvider>();
+ services.AddSearchResultItemProvider<DefaultSearchResultProvider>();
+ services.AddSearchResultItemProvider<InvoiceSearchResultProvider>();
+ services.AddScoped<SearchResultItemProviders>();
+ }
+}
diff --git a/BTCPayServer/Plugins/GlobalSearch/ISearchResultItemProvider.cs b/BTCPayServer/Plugins/GlobalSearch/ISearchResultItemProvider.cs
new file mode 100644
index 0000000..25e23ff
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/ISearchResultItemProvider.cs
@@ -0,0 +1,50 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using BTCPayServer.Security;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Plugins.GlobalSearch;
+
+public class SearchResultItemProviderContext(ClaimsPrincipal user, string userId, IUrlHelper url, IAuthorizationService authorizationService)
+{
+ public int? MaxResult { get; set; }
+ public StoreData? Store { get; set; }
+ public ClaimsPrincipal User { get; } = user;
+ public string UserId { get; } = userId;
+ public List<ResultItemViewModel> ItemResults { get; set; } = new();
+ public IUrlHelper Url { get; } = url;
+ public IAuthorizationService AuthorizationService { get; } = authorizationService;
+
+ /// <summary>
+ /// The user's query. When null, the items returned by <see cref="ISearchResultItemProvider" /> get filtered browser's side.
+ /// </summary>
+ public string? UserQuery { get; set; }
+ public async Task<bool> IsAuthorized(string policy)
+ {
+ var type = Permission.TryGetPolicyType(policy);
+ if (type == PolicyType.Store)
+ {
+ if (Store is null)
+ return false;
+ var result = await AuthorizationService.AuthorizeAsync(User, Store, new PolicyRequirement(policy));
+ return result.Succeeded;
+ }
+ else
+ {
+ var result = await AuthorizationService.AuthorizeAsync(User, null, new PolicyRequirement(policy));
+ return result.Succeeded;
+ }
+ }
+}
+
+public interface ISearchResultItemProvider
+{
+ Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken);
+}
diff --git a/BTCPayServer/Plugins/GlobalSearch/InvoiceSearchResultProvider.cs b/BTCPayServer/Plugins/GlobalSearch/InvoiceSearchResultProvider.cs
new file mode 100644
index 0000000..2f99a2c
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/InvoiceSearchResultProvider.cs
@@ -0,0 +1,67 @@
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Controllers;
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using BTCPayServer.Services.Invoices;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.Plugins.GlobalSearch;
+
+public class InvoiceSearchResultProvider(InvoiceRepository invoice,
+ IStringLocalizer stringLocalizer) : ISearchResultItemProvider
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+ const string Category = "Payments";
+ public async Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken)
+ {
+ if (context is { UserQuery: string q, Store: not null })
+ {
+ var search = new SearchString(q);
+ var invQuery = new InvoiceQuery();
+ invQuery.FillFromSearchText(search, 0);
+ invQuery.StoreId = [context.Store.Id];
+ invQuery.UserId = context.UserId;
+ invQuery.Take = (context.MaxResult ?? 10);
+ foreach (var i in await invoice.GetInvoices(invQuery, cancellationToken))
+ {
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Category = Category,
+ Title = $"{StringLocalizer["Invoice"]} ❯ {Truncate(i.Id)}",
+ Url = context.Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = i.Id }),
+ RequiredPolicy = Policies.CanViewInvoices
+ });
+ }
+ }
+ else if (context is { UserQuery: null, Store: StoreData store })
+ {
+ context.ItemResults.AddRange([
+ new ResultItemViewModel
+ {
+ Category = Category,
+ Title = "Browse the invoices",
+ Url = context.Url.Action(nameof(UIInvoiceController.ListInvoices), "UIInvoice", new { storeId = store.Id }),
+ Keywords = ["Invoices", "List", "Browse"],
+ RequiredPolicy = Policies.CanViewInvoices
+ },
+ new ResultItemViewModel
+ {
+ Category = Category,
+ Title = "Create Invoice",
+ Url = context.Url.Action(nameof(UIInvoiceController.CreateInvoice), "UIInvoice", new { storeId = store.Id }),
+ Keywords = ["Invoice"],
+ RequiredPolicy = Policies.CanCreateInvoice
+ }]);
+ }
+ }
+
+ private string Truncate(string invoiceId)
+ {
+ if (string.IsNullOrEmpty(invoiceId) || invoiceId.Length <= 8)
+ return invoiceId;
+ return $"{invoiceId.Substring(0, 4)}...{invoiceId.Substring(invoiceId.Length - 3)}";
+ }
+}
diff --git a/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs b/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
new file mode 100644
index 0000000..765a003
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/SearchResultItemProviders.cs
@@ -0,0 +1,89 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+using Microsoft.Extensions.Primitives;
+
+namespace BTCPayServer.Plugins.GlobalSearch;
+
+public class SearchResultItemProviders(
+ IEnumerable<ISearchResultItemProvider> providers,
+ IAuthorizationService authorizationService,
+ IStringLocalizer stringLocalizer,
+ UserManager<ApplicationUser> userManager)
+{
+ public async Task<GlobalSearchViewModel> GetViewModel(
+ ClaimsPrincipal user,
+ StoreData? store,
+ IUrlHelper url,
+ string? userQuery = null,
+ int? maxResult = null,
+ CancellationToken cancellationToken = default)
+ {
+ var id = userManager.GetUserId(user) ?? throw new InvalidOperationException("Invalid user");
+ var ctx = new SearchResultItemProviderContext(user, id, url, authorizationService)
+ {
+ Store = store,
+ UserQuery = userQuery,
+ MaxResult = maxResult
+ };
+ foreach (var provider in providers)
+ {
+ await provider.ProvideAsync(ctx, cancellationToken);
+ }
+
+ await FilterAuthorizedItems(ctx);
+ Translate(ctx);
+ if (ctx.ItemResults.Count > maxResult)
+ ctx.ItemResults = ctx.ItemResults.Take(maxResult.Value).ToList();
+ return new GlobalSearchViewModel()
+ {
+ Items = ctx.ItemResults,
+ StoreId = store?.Id,
+ RecentKey = $"btcpay-global-search-recents:{id}",
+ SearchUrl = url.Action("Global", "UISearch", new { area = GlobalSearchPlugin.Area })
+ };
+ }
+
+ private static async Task FilterAuthorizedItems(SearchResultItemProviderContext ctx)
+ {
+ var authorizedItems = new List<ResultItemViewModel>();
+ foreach (var item in ctx.ItemResults)
+ if (item.RequiredPolicy is null || await ctx.IsAuthorized(item.RequiredPolicy))
+ authorizedItems.Add(item);
+ ctx.ItemResults = authorizedItems;
+ }
+
+ private void Translate(SearchResultItemProviderContext ctx)
+ {
+ var original = ctx.ItemResults.ToList();
+ ctx.ItemResults.Clear();
+ foreach (var o in original)
+ {
+ var result = new ResultItemViewModel(o);
+ if (result.Title is not null)
+ result.Title = stringLocalizer[result.Title];
+ if (result.SubTitle is not null)
+ result.SubTitle = stringLocalizer[result.SubTitle];
+ if (result.Category is not null)
+ result.Category = stringLocalizer[result.Category];
+ if (result.Keywords is not null)
+ {
+ for (int i = 0; i < result.Keywords.Length; i++)
+ {
+ result.Keywords[i] = stringLocalizer[result.Keywords[i]];
+ }
+ }
+ ctx.ItemResults.Add(result);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/GlobalSearch/StaticSearchResultProvider.cs b/BTCPayServer/Plugins/GlobalSearch/StaticSearchResultProvider.cs
new file mode 100644
index 0000000..0b27672
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/StaticSearchResultProvider.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Plugins.GlobalSearch;
+
+public class ActionResultItemViewModel
+{
+ public string? RequiredPolicy { get; set; }
+ public required string Title { get; set; }
+ public string? SubTitle { get; set; }
+ public required string Action { get; set; }
+ public required string Controller { get; set; }
+ public Func<SearchResultItemProviderContext, object>? Values { get; set; }
+ public string? Category { get; set; }
+ public string[]? Keywords { get; set; }
+}
+
+public class StaticSearchResultProvider(
+ IEnumerable<ResultItemViewModel> items,
+ IEnumerable<ActionResultItemViewModel> routeItems) : ISearchResultItemProvider
+{
+ public async Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken)
+ {
+ if (context.UserQuery is not null)
+ return;
+ context.ItemResults.AddRange(items);
+
+ foreach (var item in routeItems)
+ {
+ if (item.RequiredPolicy is not null && !await context.IsAuthorized(item.RequiredPolicy))
+ continue;
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ RequiredPolicy = item.RequiredPolicy,
+ Category = item.Category,
+ Title = item.Title,
+ Keywords = item.Keywords,
+ Url = context.Url.Action(item.Action, item.Controller, item.Values?.Invoke(context)),
+ SubTitle = item.SubTitle
+ });
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/GlobalSearch/Views/GlobalSearchViewModel.cs b/BTCPayServer/Plugins/GlobalSearch/Views/GlobalSearchViewModel.cs
new file mode 100644
index 0000000..878f9d2
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/Views/GlobalSearchViewModel.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Plugins.GlobalSearch.Views;
+
+public class GlobalSearchViewModel
+{
+ public List<ResultItemViewModel> Items { get; set; }
+
+ public string GetItemsHash()
+ {
+ var json = JsonConvert.SerializeObject(this);
+ var utf8 = Encoding.UTF8.GetBytes(json);
+ var hash = SHA256.HashData(utf8).Take(10).ToArray();
+ return Convert.ToHexString(hash);
+ }
+
+ public string StoreId { get; set; }
+ public string RecentKey { get; set; }
+ public string SearchUrl { get; set; }
+}
+public class ResultItemViewModel
+{
+ public ResultItemViewModel()
+ {
+
+ }
+
+ public ResultItemViewModel(ResultItemViewModel other)
+ {
+ Title = other.Title;
+ SubTitle = other.SubTitle;
+ Category = other.Category;
+ Url = other.Url;
+ Keywords = other.Keywords?.ToArray();
+ }
+ [JsonIgnore]
+ public string RequiredPolicy { get; set; }
+ public string Title { get; set; }
+ public string SubTitle { get; set; }
+ public string Category { get; set; }
+ public string Url { get; set; }
+ public string[] Keywords { get; set; }
+}
+
diff --git a/BTCPayServer/Plugins/GlobalSearch/Views/NavExtension.cshtml b/BTCPayServer/Plugins/GlobalSearch/Views/NavExtension.cshtml
new file mode 100644
index 0000000..6711dec
--- /dev/null
+++ b/BTCPayServer/Plugins/GlobalSearch/Views/NavExtension.cshtml
@@ -0,0 +1,89 @@
+@using BTCPayServer.Client
+@using BTCPayServer.Plugins.GlobalSearch
+@using BTCPayServer.Security
+@using Microsoft.AspNetCore.Authorization
+
+@inject SearchResultItemProviders SearchResultItemProviders
+@inject IAuthorizationService authorizationService
+
+@{
+ // We don't check the result on purpose. This is just to get `GetNavStoreData` populated.
+ await authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanViewStoreSettings));
+ var vm = await SearchResultItemProviders.GetViewModel(this.User, Context.GetNavStoreData(), this.Url, cancellationToken: this.Context.RequestAborted);
+ var itemsHash = vm.GetItemsHash();
+ vm.Items = null;
+}
+
+<button id="globalSearchMobileToggle"
+ class="globalNav-btn d-lg-none"
+ type="button"
+ aria-label="@StringLocalizer["Search"]"
+ title="@StringLocalizer["Search"]"
+ data-bs-trigger="hover"
+ data-bs-placement="bottom"
+ data-global-nav-tooltip="1">
+ <vc:icon symbol="actions-search" />
+</button>
+<div id="globalSearchShell" class="globalSearch-shell">
+ <div class="globalSearch-bar">
+ <button id="globalSearchBack" type="button" class="globalSearch-back d-lg-none" aria-label="@StringLocalizer["Back"]">
+ <vc:icon symbol="back" />
+ </button>
+ <vc:icon symbol="actions-search" />
+ <label for="globalSearchInput" class="visually-hidden" text-translate="true">Search</label>
+ <input id="globalSearchInput"
+ class="globalSearch-input"
+ type="search"
+ autocomplete="off"
+ placeholder="@StringLocalizer["Search…"]"
+ aria-label="@StringLocalizer["Search"]" />
+ <span class="globalSearch-shortcut d-none d-xl-inline">/</span>
+ <button id="globalSearchClear" type="button" class="globalSearch-clear" tabindex="-1" aria-label="@StringLocalizer["Close search"]">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <div id="globalSearchResults" class="globalSearch-panel" hidden aria-live="polite"></div>
+</div>
+
+<template id="globalSearch-group-template">
+ <div class="globalSearch-group">
+ <div class="globalSearch-group-header">
+ <span class="globalSearch-group-title"></span>
+ <button type="button" class="globalSearch-group-action"></button>
+ </div>
+
+ <ul class="globalSearch-list"></ul>
+ </div>
+</template>
+
+<template id="search-item-template">
+ <li class="globalSearch-item-wrapper">
+ <a
+ type="button"
+ class="globalSearch-item globalSearch-item-button"
+ data-search-suggestion=""
+ >
+ <span class="globalSearch-item-title">
+ </span>
+ <div class="globalSearch-item-subtitle"></div>
+ </a>
+ </li>
+</template>
+<template id="globalSearch-empty-template">
+ <div class="globalSearch-empty"></div>
+</template>
+
+<script>
+ window.globalSearch = @Safe.Json(vm);
+ window.globalSearch.localItemsHash = @Safe.Json(itemsHash);
+ window.globalSearch.translate = {
+ "Type to search": @Safe.Json(StringLocalizer["Type to search"].Value),
+ "No matches found": @Safe.Json(StringLocalizer["No matches found"].Value),
+ "Recent": @Safe.Json(StringLocalizer["Recent"].Value),
+ "Clear history": @Safe.Json(StringLocalizer["Clear history"].Value),
+ "Suggested": @Safe.Json(StringLocalizer["Suggested"].Value),
+ };
+</script>
+<link href="~/plugins/GlobalSearch/global-search.css" rel="stylesheet" asp-append-version="true" />
+<script src="~/plugins/GlobalSearch/global-search.js" asp-append-version="true"></script>
+
diff --git a/BTCPayServer/Plugins/Impersonation/ImpersonationPlugin.cs b/BTCPayServer/Plugins/Impersonation/ImpersonationPlugin.cs
index 2b9ed84..0d77908 100644
--- a/BTCPayServer/Plugins/Impersonation/ImpersonationPlugin.cs
+++ b/BTCPayServer/Plugins/Impersonation/ImpersonationPlugin.cs
@@ -1,5 +1,6 @@
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
+using BTCPayServer.Plugins.GlobalSearch;
using BTCPayServer.Security;
using BTCPayServer.Services;
using Microsoft.Extensions.DependencyInjection;
@@ -26,5 +27,16 @@ public class ImpersonationPlugin : BaseBTCPayServerPlugin
new PermissionDisplay("Can impersonate the selected users", "Allows impersonation of the selected users."))
});
services.AddSingleton<IPermissionHandler, ImpersonationPermissionHandler>();
+
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewProfile,
+ Title = "Log another device from a QR Code",
+ Action = nameof(UIImpersonationController.LoginCodes),
+ Controller = "UIImpersonation",
+ Values = ctx => new { area = Area },
+ Category = "Account",
+ Keywords = ["Login", "Codes", "Login Codes", "QR", "Device", "Impersonate"]
+ });
}
}
diff --git a/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs b/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
index 35ede47..973552d 100644
--- a/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
+++ b/BTCPayServer/Plugins/Impersonation/UIImpersonationController.cs
@@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
-using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization;
using NicolasDorier.RateLimits;
diff --git a/BTCPayServer/Plugins/Impersonation/Views/UserNav.cshtml b/BTCPayServer/Plugins/Impersonation/Views/UserNav.cshtml
index b6aa0d6..046f4e5 100644
--- a/BTCPayServer/Plugins/Impersonation/Views/UserNav.cshtml
+++ b/BTCPayServer/Plugins/Impersonation/Views/UserNav.cshtml
@@ -1,4 +1,4 @@
@using BTCPayServer.Plugins.Impersonation
-<li class="nav-item nav-item-sub">
+<li class="py-1 px-3">
<a layout-menu-item="@nameof(UIImpersonationController.LoginCodes)" asp-area="@ImpersonationPlugin.Area" asp-controller="UIImpersonation" asp-action="LoginCodes" text-translate="true">Login Codes</a>
</li>
diff --git a/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs b/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs
index 3c68162..36b2fb7 100644
--- a/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs
+++ b/BTCPayServer/Plugins/Translations/TranslationsPlugin.cs
@@ -1,5 +1,9 @@
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Plugins.Bitpay.Controllers;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.Translations.Controllers;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -23,5 +27,16 @@ public class TranslationsPlugin : BaseBTCPayServerPlugin
services.TryAddSingleton<LanguagePackUpdateService>();
services.AddStartupTask<LoadTranslationsStartupTask>();
services.TryAddSingleton<IStringLocalizer>(o => o.GetRequiredService<IStringLocalizerFactory>().Create("", ""));
+
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanModifyServerSettings,
+ Title = "Localize the user interface",
+ Action = nameof(UITranslationController.ListDictionaries),
+ Controller = "UITranslation",
+ Values = _ => new { area = Area },
+ Category = "Server",
+ Keywords = ["Server", "Settings", "Translations", "Language", "Dictionary", "Localization"]
+ });
}
}
diff --git a/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs b/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs
index 68695f3..fa8a518 100644
--- a/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs
+++ b/BTCPayServer/Plugins/Wallets/UrlHelperExtensions.Wallets.cs
@@ -6,6 +6,9 @@ namespace BTCPayServer.Plugins.Wallets;
public static class UrlHelperExtensions
{
+ public static string? WalletReservedAddresses(this IUrlHelper helper, WalletId walletId) => helper.Action(nameof(UIWalletsController.ReservedAddresses), "UIWallets", new { area = WalletsPlugin.Area, walletId });
+ public static string? WalletReceive(this IUrlHelper helper, WalletId walletId) => helper.Action(nameof(UIWalletsController.WalletReceive), "UIWallets", new { area = WalletsPlugin.Area, walletId });
+ public static string? WalletSettings(this IUrlHelper helper, WalletId walletId) => helper.Action(nameof(UIStoreOnChainWalletsController.WalletSettings), "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, storeId = walletId.StoreId, cryptoCode = walletId.CryptoCode });
public static string? WalletSend(this IUrlHelper helper, WalletId walletId) => helper.Action(nameof(UIWalletsController.WalletSend), "UIWallets", new { area = WalletsPlugin.Area, walletId });
public static string? WalletTransactions(this IUrlHelper helper, string walletId) => WalletTransactions(helper, WalletId.Parse(walletId));
public static string? WalletTransactions(this IUrlHelper helper, WalletId walletId)
diff --git a/BTCPayServer/Plugins/Wallets/WalletSearchResultProvider.cs b/BTCPayServer/Plugins/Wallets/WalletSearchResultProvider.cs
new file mode 100644
index 0000000..dfcb5b9
--- /dev/null
+++ b/BTCPayServer/Plugins/Wallets/WalletSearchResultProvider.cs
@@ -0,0 +1,149 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Controllers;
+using BTCPayServer.Payments;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Invoices;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.Plugins.Wallets;
+
+public class WalletSearchResultProvider(
+ BTCPayNetworkProvider networks,
+ IStringLocalizer stringLocalizer,
+ PaymentMethodHandlerDictionary paymentMethodHandlers,
+ PrettyNameProvider prettyNameProvider) : ISearchResultItemProvider
+{
+ private const string OnChainCategory = "On-chain wallets";
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ public Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken)
+ {
+ if (context.UserQuery is not null)
+ return Task.CompletedTask;
+
+ if (context.Store is not { Id: {} storeId })
+ return Task.CompletedTask;
+
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = "List wallets",
+ Category = OnChainCategory,
+ Url = context.Url.Action(nameof(UIWalletsController.ListWallets), "UIWallets", new { area = WalletsPlugin.Area }),
+ Keywords = ["List", "Wallets"],
+ RequiredPolicy = Policies.CanModifyStoreSettings
+ });
+
+ foreach (var network in networks.GetAll().OfType<BTCPayNetwork>())
+ {
+ var walletId = new WalletId(storeId, network.CryptoCode);
+
+
+ if (paymentMethodHandlers.Support(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode)))
+ {
+
+ var translated = prettyNameProvider.PrettyName(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), false);
+ var category = StringLocalizer["On-chain wallets", network.CryptoCode].Value + " ❯ " + translated;
+ var untranslated = prettyNameProvider.PrettyName(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), true);
+
+ var settings = context.Store.GetDerivationSchemeSettings(paymentMethodHandlers, network.CryptoCode);
+ if (settings is null)
+ {
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["Set up a wallet"].Value,
+ Category = category,
+ Url = context.Url.Action(nameof(UIStoreOnChainWalletsController.SetupWallet), "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, storeId, cryptoCode = network.CryptoCode }),
+ Keywords = ["Setup", "Wallets", network.CryptoCode, translated, untranslated],
+ RequiredPolicy = WalletPolicies.CanManageWalletSettings
+ });
+ }
+ else
+ {
+ if (!network.ReadonlyWallet)
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["Create a new transaction"].Value,
+ Category = category,
+ Url = context.Url.WalletSend(walletId),
+ Keywords = ["Send", "Wallets", "Create", "transaction", network.CryptoCode, translated, untranslated],
+ RequiredPolicy = WalletPolicies.CanCreateWalletTransactions
+ });
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["Get a deposit address"].Value,
+ Category = category,
+ Url = context.Url.WalletReceive(walletId),
+ Keywords = ["Receive", "Deposit", "Address", "Wallets", network.CryptoCode, translated, untranslated],
+ RequiredPolicy = WalletPolicies.CanViewWallet
+ });
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["Browse all deposit addresses"].Value,
+ Category = category,
+ Url = context.Url.WalletReservedAddresses(walletId),
+ Keywords = ["Receive", "Deposit", "Address", "Wallets", network.CryptoCode, translated, untranslated],
+ RequiredPolicy = WalletPolicies.CanViewWallet
+ });
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["View transactions"].Value,
+ Category = category,
+ Url = context.Url.WalletTransactions(walletId),
+ Keywords = ["Transactions", "View", "Wallets", network.CryptoCode, translated, untranslated],
+ RequiredPolicy = WalletPolicies.CanViewWallet
+ });
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["Go to wallet's settings"].Value,
+ Category = category,
+ Url = context.Url.WalletSettings(walletId),
+ Keywords = ["Settings", "Wallets", network.CryptoCode, translated, untranslated],
+ RequiredPolicy = WalletPolicies.CanManageWalletSettings
+ });
+ }
+ }
+
+ // This should normally be in Lightning specific plugin
+ if (paymentMethodHandlers.Support(PaymentTypes.LN.GetPaymentMethodId(network.CryptoCode)))
+ {
+ var translated = prettyNameProvider.PrettyName(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), false);
+ var category = StringLocalizer["Lightning", network.CryptoCode].Value + " ❯ " + translated;
+ var untranslated = prettyNameProvider.PrettyName(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), true);
+
+ var lntranslated = prettyNameProvider.PrettyName(PaymentTypes.LN.GetPaymentMethodId(network.CryptoCode), false);
+ var lnuntranslated = prettyNameProvider.PrettyName(PaymentTypes.LN.GetPaymentMethodId(network.CryptoCode), true);
+
+ var settings = paymentMethodHandlers.GetLightningConfig(context.Store, network);
+ if (settings is null)
+ {
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["Set up a Lightning node"].Value,
+ Category = category,
+ Url = context.Url.Action(nameof(UIStoresController.SetupLightningNode), "UIStores", new { storeId, cryptoCode = network.CryptoCode }),
+ Keywords = ["Setup", "Wallets", network.CryptoCode, translated, untranslated, lntranslated, lnuntranslated, "Lightning"],
+ RequiredPolicy = Policies.CanModifyStoreSettings
+ });
+ }
+ else
+ {
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ Title = StringLocalizer["View the public node info"].Value,
+ Category = category,
+ Url = context.Url.Action(nameof(UIPublicLightningNodeInfoController.ShowLightningNodeInfo), "UIPublicLightningNodeInfo", new { storeId, cryptoCode = network.CryptoCode }),
+ Keywords = ["Public", "Node", "Info", "View", translated, untranslated, lntranslated, lnuntranslated, "Lightning"],
+ RequiredPolicy = Policies.CanModifyStoreSettings
+ });
+ }
+ }
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs b/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
index 373cf4c..03b21c8 100644
--- a/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
+++ b/BTCPayServer/Plugins/Wallets/WalletsPlugin.cs
@@ -17,6 +17,7 @@ public class WalletsPlugin : BaseBTCPayServerPlugin
public override void Execute(IServiceCollection services)
{
+ services.AddSearchResultItemProvider<WalletSearchResultProvider>();
services.AddTransient<HotwalletSafe>();
services.AddPolicyDefinitions(
new PolicyDefinition(
diff --git a/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs b/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
index 0fd6df9..99ee08e 100644
--- a/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
+++ b/BTCPayServer/Plugins/Webhooks/WebhooksPlugin.cs
@@ -3,9 +3,13 @@ using System;
using System.Collections.Generic;
using System.Net.Http;
using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.Translations.Controllers;
+using BTCPayServer.Plugins.Webhooks.Controllers;
using BTCPayServer.Plugins.Webhooks.HostedServices;
using BTCPayServer.Plugins.Webhooks.TriggerProviders;
using BTCPayServer.Services;
@@ -47,7 +51,18 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
});
}
- // Add built in webhooks
+ services.AddStaticSearch(new ActionResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewStoreSettings,
+ Title = "Configure webhooks",
+ Action = nameof(UIStoreWebhooksController.Webhooks),
+ Controller = "UIStoreWebhooks",
+ Values = ctx => new { area = Area, storeId = ctx.Store!.Id },
+ Category = "Store",
+ Keywords = ["Webhooks", "Configure"]
+ });
+
+ // Add built-in webhooks
AddInvoiceWebhooks(services);
AddPayoutWebhooks(services);
AddPaymentRequestWebhooks(services);
diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
index 021b735..5ccd5ab 100644
--- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs
+++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs
@@ -1039,6 +1039,20 @@ retry:
public bool IncludeArchived { get; set; } = true;
public bool IncludeRefunds { get; set; }
public bool OrderByDesc { get; set; } = true;
+
+ public void FillFromSearchText(SearchString fs, int timezoneOffset)
+ {
+ TextSearch = fs.TextSearch;
+ Unusual = fs.GetFilterBool("unusual");
+ IncludeArchived = fs.GetFilterBool("includearchived") ?? false;
+ Status = fs.GetFilterArray("status");
+ ExceptionStatus = fs.GetFilterArray("exceptionstatus");
+ StoreId = fs.GetFilterArray("storeid");
+ ItemCode = fs.GetFilterArray("itemcode");
+ OrderId = fs.GetFilterArray("orderid");
+ StartDate = fs.GetFilterDate("startdate", timezoneOffset);
+ EndDate = fs.GetFilterDate("enddate", timezoneOffset);
+ }
}
public class InvoiceStatistics : Dictionary<string, InvoiceStatistics.Contribution>
diff --git a/BTCPayServer/Services/Reporting/ReportingSearchResultProvider.cs b/BTCPayServer/Services/Reporting/ReportingSearchResultProvider.cs
new file mode 100644
index 0000000..a9341ff
--- /dev/null
+++ b/BTCPayServer/Services/Reporting/ReportingSearchResultProvider.cs
@@ -0,0 +1,45 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Controllers;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.GlobalSearch.Views;
+using Microsoft.Extensions.Localization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Services.Reporting;
+
+public class ReportingSearchResultProvider(IEnumerable<ReportProvider> reportProviders, IStringLocalizer stringLocalizer) : ISearchResultItemProvider
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+ const string Category = "Reports";
+ public Task ProvideAsync(SearchResultItemProviderContext context, CancellationToken cancellationToken)
+ {
+ if (context.Store is null)
+ return Task.CompletedTask;
+ if (context.UserQuery is not null)
+ return Task.CompletedTask;
+
+ context.ItemResults.Add(new ResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewReports,
+ Category = Category,
+ Title = StringLocalizer["Go to Reports"],
+ Keywords = ["Reports", "Go"],
+ Url = context.Url.Action(nameof(UIReportsController.StoreReports), "UIReports", new { storeId = context.Store!.Id })
+ });
+
+ context.ItemResults.AddRange(reportProviders.Select(provider => new ResultItemViewModel()
+ {
+ RequiredPolicy = Policies.CanViewReports,
+ Category = Category,
+ Url = context.Url.Action(nameof(UIReportsController.StoreReports), "UIReports", new { storeId = context.Store!.Id, viewName = provider.Name }),
+ Keywords = ["Reports", "View", provider.Name],
+ Title = StringLocalizer["View report '{0}'", provider.Name]
+ }));
+ return Task.CompletedTask;
+ }
+}
diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml
index 4b77a00..2f28a0b 100644
--- a/BTCPayServer/Views/Shared/_Layout.cshtml
+++ b/BTCPayServer/Views/Shared/_Layout.cshtml
@@ -1,6 +1,7 @@
@inject Microsoft.AspNetCore.Http.IHttpContextAccessor _context;
@inject BTCPayServer.Services.BTCPayServerEnvironment _env
@inject UserManager<ApplicationUser> _userManager
+@inject SignInManager<ApplicationUser> _signInManager
@{
var user = await _userManager.GetUserAsync(User);
@@ -17,6 +18,15 @@
</head>
<body class="d-flex flex-column flex-lg-row min-vh-100">
<header id="mainMenu" class="btcpay-header d-flex flex-column d-print-none" v-pre>
+ <div id="mainMenuHead">
+ <button id="mainMenuToggle" class="mainMenuButton" type="button" data-bs-toggle="offcanvas" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
+ <span>Menu</span>
+ </button>
+ @if (_signInManager.IsSignedIn(User))
+ {
+ <vc:global-nav />
+ }
+ </div>
<vc:main-nav />
</header>
<template id="badUrl">
diff --git a/BTCPayServer/Views/UIServer/ListPlugins.cshtml b/BTCPayServer/Views/UIServer/ListPlugins.cshtml
index 1c36852..7dc42a0 100644
--- a/BTCPayServer/Views/UIServer/ListPlugins.cshtml
+++ b/BTCPayServer/Views/UIServer/ListPlugins.cshtml
@@ -154,6 +154,7 @@
</div>
}
+<div id="plugins-installed"></div>
@if (Model.Plugins.Any())
{
<h3 class="mb-4" text-translate="true">Installed Plugins</h3>
@@ -375,6 +376,7 @@
</script>
+<div id="plugins-directory"></div>
@if (!availableAndNotInstalled.Any())
{
<div class="row mb-4">
diff --git a/BTCPayServer/wwwroot/main/layout.css b/BTCPayServer/wwwroot/main/layout.css
index d2892c7..644d1a2 100644
--- a/BTCPayServer/wwwroot/main/layout.css
+++ b/BTCPayServer/wwwroot/main/layout.css
@@ -8,6 +8,7 @@
:root {
--mobile-header-height: 4rem;
--desktop-header-height: 8rem;
+ --global-nav-height: 4rem;
--sidebar-width: 280px;
--sticky-header-height: 0; /* gets dynamically set via JavaScript */
@@ -105,8 +106,192 @@
color: var(--btcpay-header-link-active);
}
-#mainNavSettings {
- margin-top: auto;
+#mainNav .mainNav-top-right-hint {
+ margin: 0 0 var(--btcpay-space-s);
+ border: 1px solid var(--btcpay-body-border-light);
+ border-radius: var(--btcpay-border-radius-m);
+ background: transparent;
+ color: var(--btcpay-body-text-muted);
+}
+
+#mainNav .mainNav-top-right-hint-close {
+ border: 0;
+ background: transparent;
+ color: var(--btcpay-body-text-muted);
+ padding: 0;
+ margin: 0;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+#mainNav .mainNav-top-right-hint-close:hover {
+ color: var(--btcpay-body-text);
+}
+
+#mainNav .mainNav-top-right-hint-close .icon {
+ --icon-size: 1rem;
+}
+
+#mainNav .mainNav-top-right-hint[hidden] {
+ display: none !important;
+}
+
+/* Global Top Bar */
+#globalNav {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--btcpay-space-s);
+ min-width: 0;
+}
+
+#globalNav .globalNav-btn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--button-width);
+ height: var(--button-height);
+ padding: var(--button-padding);
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ outline: none;
+ color: var(--btcpay-body-text-muted);
+ border-radius: var(--btcpay-border-radius-m);
+}
+
+#globalNav .globalNav-btn:hover {
+ color: var(--btcpay-header-link-accent);
+ background: var(--btcpay-neutral-100);
+}
+
+#globalNav .globalNav-btn .globalNav-status {
+ position: absolute;
+ top: .25rem;
+ right: .25rem;
+ width: 10px;
+ height: 10px;
+ border-width: 0;
+}
+
+#globalNav .globalNav-btn .globalNav-status:before {
+ width: 6px;
+ height: 6px;
+}
+
+#globalNav #mainNavSettings {
+ display: flex;
+ align-items: center;
+ gap: var(--btcpay-space-xs);
+ flex: 0 0 auto;
+}
+
+#globalNav .globalNav-btn.menu-item {
+ margin: 0;
+ text-decoration: none;
+}
+
+#globalNav .dropdown-menu {
+ min-width: 220px;
+ border: 1px solid var(--btcpay-body-border-medium);
+ border-radius: var(--btcpay-border-radius-l);
+ background: var(--btcpay-body-bg);
+ box-shadow: 0 12px 24px rgba(0, 0, 0, .12);
+ padding: .25rem 0;
+}
+
+#globalNav #globalNavServerMenu,
+#globalNav #globalNavPluginsMenu {
+ min-width: 250px;
+}
+
+#globalNav .dropdown-menu li {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+#globalNav .dropdown-menu > li > a,
+#globalNav .dropdown-menu .menu-item,
+#globalNav .dropdown-menu .dropdown-item,
+#globalNav .dropdown-menu .nav-item .nav-link,
+#globalNav .dropdown-menu .nav-link {
+ display: block;
+ width: 100%;
+ padding: .45rem .6rem;
+ clear: both;
+ font-weight: var(--btcpay-font-weight-semibold);
+ color: var(--bs-dropdown-link-color);
+ text-decoration: none;
+ white-space: normal;
+ background-color: transparent;
+ border: 0;
+ border-radius: 0;
+}
+
+#globalNav #globalNavServerMenu > li,
+#globalNav #globalNavPluginsMenu > li,
+#globalNav #globalNavServerMenu > .nav-item,
+#globalNav #globalNavPluginsMenu > .nav-item {
+ padding: .25rem .75rem;
+}
+
+#globalNav #globalNavServerMenu > li > a,
+#globalNav #globalNavPluginsMenu > li > a,
+#globalNav #globalNavServerMenu > .nav-item > a,
+#globalNav #globalNavPluginsMenu > .nav-item > a {
+ border-radius: var(--btcpay-border-radius-m);
+ padding: .45rem .6rem;
+}
+
+#globalNav .dropdown-menu > li > a:hover,
+#globalNav .dropdown-menu .menu-item:hover,
+#globalNav .dropdown-menu .dropdown-item:hover,
+#globalNav .dropdown-menu .nav-item .nav-link:hover,
+#globalNav .dropdown-menu .nav-link:hover {
+ color: var(--bs-dropdown-link-hover-color);
+ background-color: var(--bs-dropdown-link-hover-bg);
+}
+
+#globalNav .dropdown-menu > li > a.active,
+#globalNav .dropdown-menu .menu-item.active,
+#globalNav .dropdown-menu .dropdown-item.active,
+#globalNav .dropdown-menu .nav-item .nav-link.active,
+#globalNav .dropdown-menu .nav-link.active {
+ color: var(--bs-dropdown-link-active-color);
+ background-color: var(--bs-dropdown-link-active-bg);
+}
+
+/* Global Search */
+#globalSearchModal .modal-dialog { max-width: 540px; }
+#globalSearchModal .modal-content {
+ border-radius: var(--btcpay-border-radius-l);
+ overflow: hidden;
+}
+#globalSearchResults a {
+ display: flex;
+ align-items: center;
+ gap: var(--btcpay-space-s);
+ padding: var(--btcpay-space-s) var(--btcpay-space-m);
+ color: var(--btcpay-body-text);
+ text-decoration: none;
+}
+#globalSearchResults a:hover,
+#globalSearchResults a.active {
+ background: var(--btcpay-body-border-light);
+ color: var(--btcpay-body-link);
+}
+#globalSearchResults .search-section {
+ font-size: var(--btcpay-font-size-s);
+ color: var(--btcpay-body-text-muted);
+ padding: var(--btcpay-space-xs) var(--btcpay-space-m);
+ font-weight: var(--btcpay-font-weight-semibold);
+ text-transform: uppercase;
}
.navbar-brand,
@@ -223,35 +408,35 @@
}
/* Logo */
-#mainMenuHead .main-logo {
+#StoreSelectorHome .main-logo {
display: inline-block;
height: 2rem;
}
@media (max-width: 575px) {
- #mainMenuHead .main-logo-custom {
+ #StoreSelectorHome .main-logo-custom {
max-width: 25vw;
}
- #mainMenuHead .main-logo-btcpay {
+ #StoreSelectorHome .main-logo-btcpay {
width: 1.125rem;
}
- #mainMenuHead .main-logo-btcpay .main-logo-btcpay--large {
+ #StoreSelectorHome .main-logo-btcpay .main-logo-btcpay--large {
display: none;
}
}
@media (min-width: 576px) {
- #mainMenuHead .main-logo-custom {
+ #StoreSelectorHome .main-logo-custom {
max-width: 10.5rem;
}
- #mainMenuHead .main-logo-btcpay {
+ #StoreSelectorHome .main-logo-btcpay {
width: 4.625rem;
}
- #mainMenuHead .main-logo-btcpay .main-logo-btcpay--small {
+ #StoreSelectorHome .main-logo-btcpay .main-logo-btcpay--small {
display: none;
}
}
@@ -304,6 +489,7 @@
/* Notifications */
#Notifications {
flex: 0 0 var(--button-width);
+ position: relative;
}
#NotificationsBadge {
@@ -315,15 +501,13 @@
#NotificationsHandle .icon {
--icon-size: 1.625rem;
- color: var(--btcpay-header-link);
}
-#NotificationsHandle:hover .icon {
- color: var(--btcpay-header-link-accent);
+#NotificationsHandle.dropdown-toggle::after {
+ content: none;
}
#NotificationsDropdown {
- border: 0;
border-radius: var(--btcpay-border-radius-l);
background-color: var(--btcpay-body-bg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 16%);
@@ -331,6 +515,8 @@
z-index: 2000;
top: var(--btcpay-space-xs) !important;
border: 1px solid var(--btcpay-body-border-medium);
+ left: auto;
+ right: 0;
}
#NotificationsList .icon {
@@ -461,6 +647,9 @@
}
#mainMenu {
+ --button-width: 44px;
+ --button-height: 44px;
+
position: fixed;
top: 0;
left: 0;
@@ -469,9 +658,93 @@
}
#mainMenuHead {
+ gap: var(--btcpay-space-xs);
padding: var(--btcpay-space-s) var(--btcpay-space-m);
}
+ #globalNav {
+ flex: 1 1 auto;
+ min-width: 0;
+ justify-content: flex-end;
+ gap: 0;
+ }
+
+ #globalNav #mainNavSettings {
+ gap: 0;
+ }
+
+ #globalNav .globalNav-btn {
+ width: var(--button-width);
+ height: var(--button-height);
+ }
+
+ #globalNav .globalSearch-shell {
+ display: none;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ width: 100vw;
+ min-width: 100vw;
+ max-width: none;
+ box-sizing: border-box;
+ z-index: 1048;
+ padding:
+ calc(env(safe-area-inset-top) + var(--btcpay-space-s))
+ calc(env(safe-area-inset-right) + var(--btcpay-space-m))
+ calc(env(safe-area-inset-bottom) + var(--btcpay-space-m))
+ calc(env(safe-area-inset-left) + var(--btcpay-space-m));
+ background: var(--btcpay-body-bg);
+ overflow: hidden;
+ }
+
+ #globalNav.globalSearch-mobile-open .globalSearch-shell {
+ display: flex;
+ flex-direction: column;
+ }
+
+ #globalNav.globalSearch-mobile-open #mainNavSettings,
+ #globalNav.globalSearch-mobile-open #globalSearchMobileToggle {
+ display: none !important;
+ }
+
+ #globalNav .globalSearch-bar {
+ min-height: calc(var(--mobile-header-height) - (2 * var(--btcpay-space-s)));
+ flex: 0 0 auto;
+ width: 100%;
+ gap: var(--btcpay-space-xs);
+ }
+
+ #globalNav .globalSearch-panel {
+ position: relative;
+ top: auto;
+ left: auto;
+ right: auto;
+ flex: 1 1 auto;
+ width: 100%;
+ min-height: 0;
+ max-height: none;
+ margin-top: var(--btcpay-space-s);
+ border-radius: var(--btcpay-border-radius-l);
+ border: 1px solid var(--btcpay-body-border-light);
+ box-shadow: none;
+ padding: var(--btcpay-space-s);
+ }
+
+ #globalNav .globalSearch-group-title {
+ margin: 0;
+ }
+
+ #globalNav .globalSearch-group-header {
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ #globalNav .globalSearch-item {
+ padding: .65rem var(--btcpay-space-s);
+ }
+
#mainNav {
position: fixed;
top: var(--mobile-header-height);
@@ -504,13 +777,32 @@
transition-duration: var(--btcpay-transition-duration-fast);
}
- #StoreSelector {
- margin: 0 auto;
- max-width: 60vw;
+ #mainNav #StoreSelectorHome {
+ margin: 0;
+ display: inline-flex;
+ margin-bottom: var(--btcpay-space-s);
+ }
+
+ #mainNav #StoreSelector {
+ margin: 0 0 var(--btcpay-space-m);
+ width: 100%;
+ max-width: none;
+ min-width: 0;
+ flex: 0 0 auto;
+ }
+
+ #StoreSelectorToggle {
+ min-width: 0;
+ padding-left: .5rem;
+ padding-right: .5rem;
+ }
+
+ #StoreSelectorToggle span {
+ min-width: 0;
}
#Notifications {
- margin-left: var(--btcpay-space-s);
+ margin-left: 0;
}
#mainMenuToggle {
@@ -610,6 +902,7 @@
margin-right: 1.5rem;
border-bottom-color: var(--btcpay-body-border-light);
}
+
}
@media (min-width: 992px) {
@@ -631,29 +924,37 @@
}
#mainMenuHead {
- flex-wrap: wrap;
- padding: var(--btcpay-space-m) 1.5rem;
+ padding: 0;
}
- #Notifications {
- order: 1;
- margin-left: auto;
+ #globalNav {
+ position: fixed;
+ top: 0;
+ left: var(--sidebar-width);
+ right: 0;
+ z-index: 1031;
+ min-height: var(--global-nav-height);
+ padding: var(--btcpay-space-s) var(--content-padding-horizontal);
+ justify-content: space-between;
+ background: var(--btcpay-body-bg);
}
#StoreSelector {
- order: 2;
margin-top: var(--btcpay-space-m);
/* Make sure we are actually taking up all of the space or else you end up with this: https://github.com/btcpayserver/btcpayserver/issues/3972 */
min-width: 100%;
}
+ #mainNav #StoreSelector {
+ margin-bottom: var(--btcpay-space-m);
+ }
+
#mainMenuToggle,
#mainMenu .offcanvas-backdrop {
display: none !important;
}
#NotificationsDropdown {
- inset: calc(var(--button-height) * -1 - var(--btcpay-space-s)) auto auto calc(var(--button-width) + var(--btcpay-space-s)) !important;
width: 400px;
}
@@ -696,7 +997,7 @@
@media (min-width: 992px) and (max-height: 799px) {
:root {
- --content-padding-top: 1.25rem;
+ --content-padding-top: calc(var(--global-nav-height) + 1.25rem);
}
}
@@ -705,14 +1006,3 @@
top: 1.25rem;
}
}
-
-@media (max-width: 449px) {
- #StoreSelector {
- max-width: 40vw;
- flex-shrink: 1;
- }
-
- #StoreSelectorToggle .icon.icon-store {
- display: none;
- }
-}
diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js
index c837eaf..f3a223e 100644
--- a/BTCPayServer/wwwroot/main/site.js
+++ b/BTCPayServer/wwwroot/main/site.js
@@ -257,8 +257,63 @@ const reinsertSvgUseElements = () => {
});
};
+const initGlobalNavTooltips = () => {
+ const header = document.getElementById('mainMenuHead');
+ if (!header) return;
+
+ if (window.bootstrap?.Dropdown) {
+ header.addEventListener('show.bs.dropdown', event => {
+ const source = event.target;
+ if (!(source instanceof Element)) return;
+
+ const currentToggle = source.matches('[data-bs-toggle="dropdown"]')
+ ? source
+ : source.querySelector('[data-bs-toggle="dropdown"]');
+ if (!(currentToggle instanceof Element)) return;
+
+ header.querySelectorAll('[data-bs-toggle="dropdown"][aria-expanded="true"]').forEach(openToggle => {
+ if (openToggle === currentToggle) return;
+ window.bootstrap.Dropdown.getOrCreateInstance(openToggle).hide();
+ });
+ });
+ }
+
+ if (!window.bootstrap?.Tooltip) return;
+ const tooltipTargets = Array.from(header.querySelectorAll('[data-global-nav-tooltip]'))
+ // Bootstrap supports only one component instance per element.
+ // Dropdown toggles therefore cannot also be Bootstrap tooltips.
+ .filter(target => (target.dataset.bsToggle || '').toLowerCase() !== 'dropdown');
+ if (!tooltipTargets.length) return;
+ const tooltipTargetSet = new Set(tooltipTargets);
+
+ const getTooltipOptions = target => ({
+ trigger: target.dataset.bsTrigger || 'hover',
+ placement: target.dataset.bsPlacement || 'bottom'
+ });
+ const hideAllTooltips = () => {
+ tooltipTargets.forEach(target => {
+ window.bootstrap.Tooltip.getInstance(target)?.hide();
+ });
+ };
+
+ tooltipTargets.forEach(target => {
+ window.bootstrap.Tooltip.getOrCreateInstance(target, getTooltipOptions(target));
+ });
+
+ header.addEventListener('click', event => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const tooltipTarget = target.closest('[data-global-nav-tooltip]');
+ if (!(tooltipTarget instanceof Element) || !tooltipTargetSet.has(tooltipTarget)) return;
+ window.requestAnimationFrame(hideAllTooltips);
+ });
+ header.addEventListener('shown.bs.dropdown', hideAllTooltips);
+ header.addEventListener('hide.bs.dropdown', hideAllTooltips);
+};
+
document.addEventListener("DOMContentLoaded", () => {
reinsertSvgUseElements();
+ initGlobalNavTooltips();
// sticky header
const stickyHeader = document.querySelector('#mainContent > section .sticky-header');
if (stickyHeader) {
@@ -445,8 +500,45 @@ document.addEventListener("DOMContentLoaded", () => {
})
}
- // Menu collapses
const mainNav = document.getElementById('mainNav')
+ const closeMobileNav = () => {
+ if (!mainNav || !window.matchMedia('(max-width: 991px)').matches || !mainNav.classList.contains('show')) return;
+ if (window.bootstrap?.Offcanvas) {
+ window.bootstrap.Offcanvas.getOrCreateInstance(mainNav).hide();
+ }
+ }
+
+ if (mainNav) {
+ delegate('click', '#mainNav a[href]', closeMobileNav)
+
+ let startX = 0;
+ let startY = 0;
+ let trackingSwipe = false;
+
+ mainNav.addEventListener('touchstart', e => {
+ if (!mainNav.classList.contains('show') || !e.touches[0]) return;
+ startX = e.touches[0].clientX;
+ startY = e.touches[0].clientY;
+ trackingSwipe = true;
+ }, { passive: true });
+
+ mainNav.addEventListener('touchmove', e => {
+ if (!trackingSwipe || !e.touches[0]) return;
+ const currentX = e.touches[0].clientX;
+ const currentY = e.touches[0].clientY;
+ const deltaX = currentX - startX;
+ const deltaY = currentY - startY;
+ if (Math.abs(deltaX) < 64 || Math.abs(deltaX) < Math.abs(deltaY)) return;
+ if (deltaX < 0) closeMobileNav();
+ trackingSwipe = false;
+ }, { passive: true });
+
+ mainNav.addEventListener('touchend', () => {
+ trackingSwipe = false;
+ }, { passive: true });
+ }
+
+ // Menu collapses
if (mainNav) {
const COLLAPSED_KEY = 'btcpay-nav-collapsed'
delegate('show.bs.collapse', '#mainNav', (e) => {
diff --git a/BTCPayServer/wwwroot/plugins/GlobalSearch/global-search.css b/BTCPayServer/wwwroot/plugins/GlobalSearch/global-search.css
new file mode 100644
index 0000000..8183e42
--- /dev/null
+++ b/BTCPayServer/wwwroot/plugins/GlobalSearch/global-search.css
@@ -0,0 +1,314 @@
+#globalNav .globalSearch-shell {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: min(44rem, calc(100vw - var(--sidebar-width) - 22rem));
+}
+
+@keyframes global-search-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+#globalNav .globalSearch-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--btcpay-space-s);
+ height: var(--button-height);
+ padding: 0 var(--btcpay-space-m);
+ border: 1px solid var(--btcpay-body-border-light);
+ border-radius: var(--btcpay-border-radius-l);
+ background: var(--btcpay-body-bg);
+ transition: border-color var(--btcpay-transition-duration-fast), box-shadow var(--btcpay-transition-duration-fast);
+}
+
+#globalNav .globalSearch-bar:hover,
+#globalNav .globalSearch-shell.is-open .globalSearch-bar,
+#globalNav .globalSearch-bar:focus-within {
+ border-color: var(--btcpay-body-border-medium);
+ box-shadow: 0 0 0 1px var(--btcpay-body-border-light);
+}
+
+#globalNav .globalSearch-bar .icon {
+ flex: 0 0 auto;
+ color: var(--btcpay-body-text-muted);
+}
+
+#globalNav .globalSearch-bar > .icon,
+#globalNav .globalSearch-clear .icon,
+#globalNav .globalSearch-back .icon {
+ --icon-size: 1.1rem;
+
+ width: 1.1rem;
+ height: 1.1rem;
+}
+
+#globalNav .globalSearch-shortcut {
+ margin-left: auto;
+ color: var(--btcpay-body-text-muted);
+ font-size: 1.1rem;
+ line-height: 1;
+ font-weight: var(--btcpay-font-weight-semibold);
+ min-width: 1rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+#globalNav .globalSearch-input {
+ flex: 1;
+ border: 0;
+ background: transparent;
+ color: var(--btcpay-body-text);
+ min-width: 0;
+}
+
+#globalNav .globalSearch-input:focus {
+ outline: none;
+}
+
+#globalNav .globalSearch-clear,
+#globalNav .globalSearch-back {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ color: var(--btcpay-body-text-muted);
+ width: var(--button-width);
+ height: var(--button-height);
+ border-radius: 50%;
+ flex: 0 0 auto;
+}
+
+#globalNav .globalSearch-clear {
+ display: none;
+}
+
+#globalNav .globalSearch-shell.has-query .globalSearch-clear,
+#globalNav.globalSearch-mobile-open .globalSearch-clear {
+ display: inline-flex;
+}
+
+#globalNav .globalSearch-shell.has-query .globalSearch-shortcut {
+ display: none !important;
+}
+
+#globalNav .globalSearch-clear:hover,
+#globalNav .globalSearch-back:hover {
+ color: var(--btcpay-body-text);
+ background: var(--btcpay-neutral-200);
+}
+
+#globalNav .globalSearch-shell.is-loading .globalSearch-shortcut,
+#globalNav .globalSearch-shell.is-loading .globalSearch-clear {
+ display: none !important;
+}
+
+#globalNav .globalSearch-shell.is-loading .globalSearch-bar::after {
+ content: "";
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid var(--btcpay-body-border-medium);
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: global-search-spin .8s linear infinite;
+}
+
+#globalNav .globalSearch-panel {
+ position: absolute;
+ top: calc(100% + var(--btcpay-space-xs));
+ left: 0;
+ right: 0;
+ z-index: 1042;
+ max-height: min(65vh, 34rem);
+ overflow-y: auto;
+ border: 1px solid var(--btcpay-body-border-medium);
+ border-radius: var(--btcpay-border-radius-l);
+ background: var(--btcpay-body-bg);
+ box-shadow: 0 12px 32px rgba(0, 0, 0, .16);
+ padding: var(--btcpay-space-s);
+}
+
+#globalNav .globalSearch-group {
+ margin-bottom: var(--btcpay-space-m);
+}
+
+#globalNav .globalSearch-group:last-child {
+ margin-bottom: 0;
+}
+
+#globalNav .globalSearch-group-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--btcpay-space-s);
+ margin: 0 var(--btcpay-space-s) var(--btcpay-space-xs);
+}
+
+#globalNav .globalSearch-group-title {
+ display: block;
+ color: var(--btcpay-body-text-muted);
+ font-size: var(--btcpay-font-size-xs);
+ font-weight: var(--btcpay-font-weight-semibold);
+ letter-spacing: .02em;
+ margin: 0;
+ text-transform: uppercase;
+}
+
+#globalNav .globalSearch-group-action {
+ border: 0;
+ background: transparent;
+ color: var(--btcpay-body-link);
+ font-size: var(--btcpay-font-size-xs);
+ font-weight: var(--btcpay-font-weight-semibold);
+ padding: 0;
+ text-decoration: underline;
+}
+
+#globalNav .globalSearch-group-action:hover {
+ color: var(--btcpay-body-link-accent);
+}
+
+#globalNav .globalSearch-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+#globalNav .globalSearch-item {
+ display: block;
+ width: 100%;
+ border-radius: var(--btcpay-border-radius-m);
+ color: var(--btcpay-body-link);
+ padding: var(--btcpay-space-s);
+ text-decoration: none;
+ text-align: left;
+ border: 0;
+ background: transparent;
+}
+
+#globalNav .globalSearch-item-button {
+ cursor: pointer;
+}
+
+#globalNav .globalSearch-item:hover {
+ background: var(--btcpay-neutral-100);
+}
+
+#globalNav .globalSearch-item-title {
+ display: flex;
+ align-items: center;
+ gap: var(--btcpay-space-s);
+ color: var(--btcpay-body-text);
+ font-weight: var(--btcpay-font-weight-semibold);
+ line-height: 1.35;
+}
+
+#globalNav .globalSearch-item-badge {
+ color: var(--btcpay-body-text-muted);
+ font-size: var(--btcpay-font-size-xs);
+ font-weight: var(--btcpay-font-weight-semibold);
+}
+
+#globalNav .globalSearch-item-subtitle {
+ color: var(--btcpay-body-text-muted);
+ font-size: var(--btcpay-font-size-s);
+ margin-top: .1rem;
+}
+
+#globalNav .globalSearch-empty {
+ color: var(--btcpay-body-text-muted);
+ font-size: var(--btcpay-font-size-s);
+ padding: var(--btcpay-space-s);
+}
+
+body.global-search-open {
+ overflow: hidden;
+}
+
+@media (max-width: 991px) {
+ body.global-search-open .tooltip {
+ display: none !important;
+ }
+}
+
+@media (max-width: 991px) {
+ #globalNav .globalSearch-shell {
+ display: none;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ width: 100vw;
+ min-width: 100vw;
+ max-width: 100vw;
+ height: 100vh;
+ height: 100dvh;
+ box-sizing: border-box;
+ z-index: 1048;
+ padding:
+ calc(env(safe-area-inset-top, 0px) + var(--btcpay-space-s))
+ calc(env(safe-area-inset-right, 0px) + var(--btcpay-space-m))
+ calc(env(safe-area-inset-bottom, 0px) + var(--btcpay-space-m))
+ calc(env(safe-area-inset-left, 0px) + var(--btcpay-space-m));
+ background: var(--btcpay-body-bg);
+ overflow: hidden;
+ }
+
+ #globalNav.globalSearch-mobile-open .globalSearch-shell {
+ display: flex;
+ flex-direction: column;
+ }
+
+ #globalNav.globalSearch-mobile-open #mainNavSettings,
+ #globalNav.globalSearch-mobile-open #globalSearchMobileToggle {
+ display: none !important;
+ }
+
+ #globalNav .globalSearch-bar {
+ min-height: calc(var(--mobile-header-height) - (2 * var(--btcpay-space-s)));
+ width: 100%;
+ flex: 0 0 auto;
+ gap: var(--btcpay-space-xs);
+ padding: 0 var(--btcpay-space-s);
+ }
+
+ #globalNav .globalSearch-input {
+ /* Prevent iOS zoom on focus while keeping desktop font-size unchanged */
+ font-size: 16px;
+ }
+
+ #globalNav .globalSearch-panel {
+ position: relative;
+ top: auto;
+ right: auto;
+ left: auto;
+ flex: 1 1 auto;
+ width: 100%;
+ min-height: 0;
+ max-height: none;
+ margin-top: var(--btcpay-space-s);
+ border: 1px solid var(--btcpay-body-border-light);
+ border-radius: var(--btcpay-border-radius-l);
+ box-shadow: none;
+ padding: var(--btcpay-space-s);
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ #globalNav .globalSearch-group-title {
+ margin: 0;
+ }
+
+ #globalNav .globalSearch-group-header {
+ margin-left: 0;
+ margin-right: 0;
+ }
+
+ #globalNav .globalSearch-item {
+ padding: .65rem var(--btcpay-space-s);
+ }
+}
diff --git a/BTCPayServer/wwwroot/plugins/GlobalSearch/global-search.js b/BTCPayServer/wwwroot/plugins/GlobalSearch/global-search.js
new file mode 100644
index 0000000..052dc8b
--- /dev/null
+++ b/BTCPayServer/wwwroot/plugins/GlobalSearch/global-search.js
@@ -0,0 +1,531 @@
+(function () {
+ var vm = window.globalSearch;
+ var firstMatch;
+
+ var initGlobalSearchInitiated = false;
+ const initGlobalSearch = () => {
+ if (initGlobalSearchInitiated) return;
+ initGlobalSearchInitiated = true;
+ // removeDups remove returns an array with no duplicates.
+ // it also merges the keywords of the same item.
+ function removeDups(localIndex) {
+ var noDups = [];
+ var localIndexMap = new Map();
+ localIndex.forEach(item => {
+ item.keywords ??= [];
+ var key = JSON.stringify({category: item.category, title: item.title});
+ if (!localIndexMap.has(key)) {
+ localIndexMap.set(key, item)
+ noDups.push(item);
+ }
+ else
+ {
+ var existing = localIndexMap.get(key);
+ item.keywords.forEach(keyword => {existing.keywords.push(keyword)})
+ }
+ });
+ return noDups;
+ }
+
+
+ const nav = document.getElementById('globalNav');
+ const shell = document.getElementById('globalSearchShell');
+ const mobileToggle = document.getElementById('globalSearchMobileToggle');
+ const input = document.getElementById('globalSearchInput');
+ const clearButton = document.getElementById('globalSearchClear');
+ const backButton = document.getElementById('globalSearchBack');
+ const resultsElement = document.getElementById('globalSearchResults');
+ if (!shell || !input || !resultsElement) return;
+
+ const localIndexTmp = [];
+ vm.items.forEach(item => localIndexTmp.push(item));
+ const localIndex = removeDups(localIndexTmp);
+
+ const now = new Date();
+ const todayIso = now.toISOString().slice(0, 10);
+ const yesterday = new Date(now);
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1);
+ const yesterdayIso = yesterday.toISOString().slice(0, 10);
+ const suggestedQueries = [
+ {query: `date:${todayIso}`, hint: 'Find invoices and requests created today'},
+ {query: `date:${yesterdayIso}`, hint: 'Find invoices and requests from yesterday'},
+ {query: 'tx:4e3a67', hint: 'Find by transaction id (prefix supported)'},
+ {query: 'server settings', hint: 'Jump to server settings pages quickly'}
+ ];
+
+ let latestSearchToken = 0;
+ let panelOpen = false;
+ let navigationFeedbackTimeout = null;
+ const desktopMediaQuery = window.matchMedia('(min-width: 992px)');
+ const setBodySearchState = isOpen => {
+ if (isOpen) document.body.classList.add('global-search-open');
+ else document.body.classList.remove('global-search-open');
+ };
+
+ setBodySearchState(false);
+
+ const isMobileSearchOpen = () => nav.classList.contains('globalSearch-mobile-open');
+ const setLoadingState = isLoading => {
+ shell.classList.toggle('is-loading', isLoading);
+ if (!isLoading && navigationFeedbackTimeout) {
+ window.clearTimeout(navigationFeedbackTimeout);
+ navigationFeedbackTimeout = null;
+ }
+ };
+ const syncSearchActionState = () => {
+ const hasQuery = !!input.value.trim();
+ shell.classList.toggle('has-query', hasQuery);
+ };
+
+ const showPanel = () => {
+ panelOpen = true;
+ shell.classList.add('is-open');
+ resultsElement.hidden = false;
+ };
+
+ const hidePanel = () => {
+ panelOpen = false;
+ shell.classList.remove('is-open');
+ resultsElement.hidden = true;
+ setLoadingState(false);
+ };
+
+ const openMobileSearch = () => {
+ if (desktopMediaQuery.matches) return;
+ const mainNav = document.getElementById('mainNav');
+ if (mainNav && mainNav.classList.contains('show') && window.bootstrap?.Offcanvas) {
+ window.bootstrap.Offcanvas.getOrCreateInstance(mainNav).hide();
+ }
+ nav.classList.add('globalSearch-mobile-open');
+ setBodySearchState(true);
+ input.focus();
+ input.select();
+ };
+
+ const closeMobileSearch = () => {
+ nav.classList.remove('globalSearch-mobile-open');
+ setBodySearchState(false);
+ };
+
+ const focusSearch = () => {
+ if (!desktopMediaQuery.matches && !isMobileSearchOpen()) {
+ openMobileSearch();
+ }
+ input.focus();
+ input.select();
+ };
+
+ const closeSearchUi = () => {
+ hidePanel();
+ if (!desktopMediaQuery.matches) {
+ closeMobileSearch();
+ }
+ };
+
+ const normalizeResult = result => {
+ if (!result || !result.url || !result.title) return null;
+ let resolvedUrl;
+ try {
+ resolvedUrl = new URL(result.url, window.location.href);
+ } catch {
+ return null;
+ }
+ const protocol = (resolvedUrl.protocol || '').toLowerCase();
+ const isHttpLike = protocol === 'http:' || protocol === 'https:';
+ if (!isHttpLike && protocol !== 'mailto:' && protocol !== 'tel:') return null;
+ const normalizedUrl = isHttpLike && resolvedUrl.origin === window.location.origin
+ ? `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`
+ : resolvedUrl.toString();
+ return {
+ title: result.title,
+ subtitle: result.subtitle || '',
+ category: result.category || 'Result',
+ url: normalizedUrl
+ };
+ };
+
+ const toSuggestionResult = item => ({
+ title: item.query,
+ subtitle: item.hint,
+ category: 'Try searching',
+ query: item.query
+ });
+
+ const clearAndFocus = () => {
+ if (input.value.trim()) {
+ input.value = '';
+ setLoadingState(false);
+ syncSearchActionState();
+ focusSearch();
+ renderInitial();
+ return;
+ }
+ if (isMobileSearchOpen()) {
+ closeSearchUi();
+ return;
+ }
+ hidePanel();
+ };
+
+ const createResultItem = result => {
+ const template = document.getElementById('search-item-template');
+ const fragment = template.content.cloneNode(true);
+
+ const listItem = fragment.querySelector('li');
+ let button = fragment.querySelector('a');
+ if (result.query) {
+ const suggestionButton = document.createElement('button');
+ suggestionButton.type = 'button';
+ suggestionButton.className = button.className;
+ suggestionButton.dataset.searchSuggestion = result.query;
+ while (button.firstChild) suggestionButton.appendChild(button.firstChild);
+ button.replaceWith(suggestionButton);
+ button = suggestionButton;
+ } else {
+ button.href = result.url;
+ delete button.dataset.searchSuggestion;
+ }
+ const title = fragment.querySelector('.globalSearch-item-title');
+ const subtitle = fragment.querySelector('.globalSearch-item-subtitle');
+
+ title.firstChild.textContent = result.title;
+
+ if (result.subtitle) {
+ subtitle.textContent = result.subtitle;
+ } else {
+ subtitle.remove();
+ }
+
+ if (!result.query) {
+ button.addEventListener('click', event => {
+ addGlobalSearchRecent(result);
+ const isModifiedClick = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
+ const isPrimaryClick = event.button === 0 && !isModifiedClick;
+ if (!isPrimaryClick || button.target === '_blank') return;
+ hidePanel();
+ setLoadingState(true);
+ navigationFeedbackTimeout = window.setTimeout(() => setLoadingState(false), 2000);
+ });
+ }
+ return listItem;
+ };
+
+ const renderGroup = (title, entries, actionLabel = null, actionDataAttribute = null) => {
+ if (!entries?.length) return null;
+
+ const template = document.getElementById('globalSearch-group-template');
+ const fragment = template.content.cloneNode(true);
+
+ const group = fragment.querySelector('.globalSearch-group');
+ const titleEl = fragment.querySelector('.globalSearch-group-title');
+ const actionBtn = fragment.querySelector('.globalSearch-group-action');
+ const list = fragment.querySelector('.globalSearch-list');
+
+ titleEl.textContent = title;
+
+ // Optional action button
+ if (actionLabel && actionDataAttribute) {
+ actionBtn.tabIndex = -1;
+ actionBtn.textContent = actionLabel;
+ actionBtn.setAttribute(actionDataAttribute, '1');
+ } else {
+ actionBtn.remove();
+ }
+
+ for (const entry of entries) {
+ list.appendChild(createResultItem(entry));
+ }
+
+ return group;
+ };
+
+ const renderEmpty = text => {
+ const template = document.getElementById('globalSearch-empty-template');
+ const fragment = template.content.cloneNode(true);
+ const empty = fragment.querySelector('.globalSearch-empty');
+ empty.textContent = vm.translate[text];
+ resultsElement.innerHTML = '';
+ resultsElement.appendChild(empty);
+ };
+
+ const refreshRecentMetadata = entries => {
+ const localByUrl = new Map(localIndex.map(item => [item.url, item]));
+ return entries.map(entry => {
+ const local = localByUrl.get(entry.url);
+ if (!local) return entry;
+ return {
+ title: local.title || entry.title,
+ subtitle: local.subtitle || entry.subtitle,
+ category: local.category || entry.category,
+ url: entry.url
+ };
+ });
+ };
+
+ const renderInitial = () => {
+ setLoadingState(false);
+ syncSearchActionState();
+ showPanel();
+ resultsElement.innerHTML = '';
+ firstMatch = null;
+ const recent = refreshRecentMetadata(loadGlobalSearchRecents())
+ .map(normalizeResult)
+ .filter(Boolean);
+ const recentGroup = renderGroup(vm.translate['Recent'], recent, vm.translate['Clear history'], 'data-clear-search-history');
+ if (recentGroup) resultsElement.appendChild(recentGroup);
+ const suggestions = suggestedQueries.map(toSuggestionResult);
+ const suggestedGroup = renderGroup(vm.translate['Suggested'], suggestions);
+ if (suggestedGroup) {
+ resultsElement.appendChild(suggestedGroup);
+ }
+
+ if (!recentGroup && !suggestedGroup) {
+ renderEmpty('Type to search');
+ }
+ else
+ {
+ firstMatch = recent[0] ?? suggestions[0] ?? null;
+ }
+ };
+
+ // This is a case-insensitive search.
+ // The query is split, and all the parts need to match the item.
+ // For example, "Bit wall" can match "Bitcoin wallet", but so does "wall bit"
+ const searchLocal = query => {
+ if (!query) return [];
+ var normalized = query.toLowerCase().split(/\s+/);
+
+ var found = 0;
+ return localIndex
+ .filter(item => {
+ if (found >= 12)
+ return false;
+ const title = (item.title || '');
+ const keywords = (item.keywords || []);
+ var all = [];
+ keywords.forEach(keyword => { all.push(keyword.toLowerCase()); })
+ all.push(title.toLowerCase());
+ if (item.category)
+ all.push(item.category.toLowerCase());
+ var match = normalized.every(n => all.some(a => a.startsWith(n)));
+ if (match) found++;
+ return match;
+ });
+ };
+
+ const searchRemote = async query => {
+ if (!query || query.length < 2) return [];
+ const url = getSearchUrl();
+ url.searchParams.set('q', query);
+ url.searchParams.set('take', '25');
+ const response = await fetch(url.toString(), {credentials: 'include'});
+ if (!response.ok) return [];
+ return await response.json();
+ };
+
+ const renderResults = entries => {
+ firstMatch = entries[0] ?? null;
+ resultsElement.innerHTML = '';
+ if (!entries.length) {
+ renderEmpty('No matches found');
+ return;
+ }
+
+ const grouped = entries.reduce((acc, item) => {
+ const key = item.category || 'Results';
+ if (!acc[key]) acc[key] = [];
+ acc[key].push(item);
+ return acc;
+ }, {});
+
+ Object.keys(grouped).forEach(groupName => {
+ const group = renderGroup(groupName, grouped[groupName]);
+ if (group) resultsElement.appendChild(group);
+ });
+ };
+
+ const selectFirstMatch = () => {
+ if (!firstMatch?.url) return false;
+ addGlobalSearchRecent(firstMatch);
+ hidePanel();
+ setLoadingState(true);
+ navigationFeedbackTimeout = window.setTimeout(() => setLoadingState(false), 2000);
+ window.location.assign(firstMatch.url);
+ return true;
+ };
+
+ const runSearch = async () => {
+ const query = input.value.trim();
+ setLoadingState(false);
+ syncSearchActionState();
+ if (!query) {
+ renderInitial();
+ return;
+ }
+
+ showPanel();
+ const token = ++latestSearchToken;
+ const localMatches = searchLocal(query);
+ renderResults(localMatches);
+
+ if (localMatches.length === 0)
+ {
+ let remoteMatches = [];
+ try {
+ remoteMatches = await searchRemote(query);
+ } catch {
+ remoteMatches = [];
+ }
+ if (token !== latestSearchToken || !panelOpen) return;
+ renderResults(remoteMatches);
+ }
+ };
+
+ mobileToggle?.addEventListener('click', () => {
+ openMobileSearch();
+ renderInitial();
+ });
+ backButton?.addEventListener('click', closeSearchUi);
+ clearButton?.addEventListener('click', clearAndFocus);
+ input.addEventListener('focus', () => {
+ setLoadingState(false);
+ syncSearchActionState();
+ if (input.value.trim()) {
+ runSearch();
+ } else {
+ renderInitial();
+ }
+ });
+ input.addEventListener('input', () => {
+ syncSearchActionState();
+ runSearch();
+ });
+ input.addEventListener('keydown', e => {
+ if (e.key !== 'Enter') return;
+ if (!selectFirstMatch()) return;
+ e.preventDefault();
+ });
+ resultsElement.addEventListener('click', e => {
+ const clearHistory = e.target.closest('[data-clear-search-history]');
+ if (clearHistory) {
+ e.preventDefault();
+ clearGlobalSearchRecents();
+ renderInitial();
+ return;
+ }
+ const suggestion = e.target.closest('[data-search-suggestion]');
+ if (!suggestion) return;
+ e.preventDefault();
+ input.value = suggestion.dataset.searchSuggestion || '';
+ input.focus();
+ runSearch();
+ });
+
+ document.addEventListener('keydown', e => {
+ const openShortcut = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k';
+ const slashShortcut = e.key === '/' && !isEditableElement(e.target);
+ if (openShortcut || slashShortcut) {
+ e.preventDefault();
+ focusSearch();
+ if (input.value.trim()) {
+ runSearch();
+ } else {
+ renderInitial();
+ }
+ return;
+ }
+
+ if (e.key === 'Escape' && (panelOpen || isMobileSearchOpen())) {
+ e.preventDefault();
+ closeSearchUi();
+ }
+ });
+
+ document.addEventListener('click', e => {
+ const target = e.target;
+ if (target instanceof Element && (shell.contains(target) || mobileToggle?.contains(target))) {
+ return;
+ }
+ if (panelOpen || isMobileSearchOpen()) {
+ closeSearchUi();
+ }
+ });
+
+ desktopMediaQuery.addEventListener('change', e => {
+ if (e.matches) {
+ closeMobileSearch();
+ hidePanel();
+ }
+ });
+
+ syncSearchActionState();
+ };
+
+ const isEditableElement = element => {
+ if (!element) return false;
+ const tagName = (element.tagName || '').toLowerCase();
+ return tagName === 'input' || tagName === 'textarea' || tagName === 'select' || element.isContentEditable;
+ };
+
+ const GLOBAL_SEARCH_RECENTS_KEY = vm.recentKey || 'btcpay-global-search-recents';
+
+ const loadGlobalSearchRecents = () => {
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(GLOBAL_SEARCH_RECENTS_KEY) || '[]');
+ return Array.isArray(parsed) ? parsed.slice(0, 8) : [];
+ } catch (e) {
+ return [];
+ }
+ };
+
+ const saveGlobalSearchRecents = items => {
+ window.localStorage.setItem(GLOBAL_SEARCH_RECENTS_KEY, JSON.stringify(items.slice(0, 8)));
+ };
+
+ const clearGlobalSearchRecents = () => {
+ try {
+ window.localStorage.removeItem(GLOBAL_SEARCH_RECENTS_KEY);
+ } catch (e) {
+ saveGlobalSearchRecents([]);
+ }
+ };
+
+ const addGlobalSearchRecent = result => {
+ if (!result || !result.url) return;
+ const current = loadGlobalSearchRecents().filter(item => item.url !== result.url);
+ current.unshift({
+ title: result.title,
+ subtitle: result.subtitle || '',
+ category: result.category || 'Page',
+ url: result.url
+ });
+ saveGlobalSearchRecents(current);
+ };
+
+ const getSearchUrl = () => {
+ const url = new URL(vm.searchUrl, window.location.origin);
+ if (vm.storeId) url.searchParams.set('storeId', vm.storeId);
+ return url;
+ };
+
+ window.globalSearch.initGlobalSearch = initGlobalSearch;
+ window.globalSearch.getSearchUrl = getSearchUrl;
+})();
+
+let fetchItems = async function () {
+ var url = window.globalSearch.getSearchUrl();
+ // The local items are mainly static, including their hash in fetch will avoid refetching them if there was no change.
+ url.searchParams.set('hash', window.globalSearch.localItemsHash);
+ var response = await fetch(url);
+ if (!response.ok)
+ return;
+ window.globalSearch.items = await response.json();
+ if (document.readyState !== 'loading')
+ window.globalSearch.initGlobalSearch();
+};
+fetchItems();
+
+document.addEventListener("DOMContentLoaded", () => {
+ if (window.globalSearch.items)
+ window.globalSearch.initGlobalSearch();
+});
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.