Allow disabling notifications per user and disabling specific notifications per user (#1991)

* Allow disabling notifications per user and disabling specific notifications per use

closes #1974

* Add disable notifs for all users

* fix term generator for notifications

* sow checkboxes instead of multiselect when js is enabled

* remove js dependency

* fix notif conditions
This commit is contained in:
Andrew Camilleri 2020-10-20 13:09:09 +02:00 committed by GitHub
parent 933e0c30bf
commit 4d0b402e8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 315 additions and 64 deletions

View file

@ -2,10 +2,17 @@ using System;
namespace BTCPayServer.Contracts namespace BTCPayServer.Contracts
{ {
public abstract class BaseNotification
{
public abstract string Identifier { get; }
public abstract string NotificationType { get; }
}
public interface INotificationHandler public interface INotificationHandler
{ {
string NotificationType { get; } string NotificationType { get; }
Type NotificationBlobType { get; } Type NotificationBlobType { get; }
public (string identifier, string name)[] Meta { get; }
void FillViewModel(object notification, NotificationViewModel vm); void FillViewModel(object notification, NotificationViewModel vm);
} }

View file

@ -28,5 +28,6 @@ namespace BTCPayServer.Data
public List<U2FDevice> U2FDevices { get; set; } public List<U2FDevice> U2FDevices { get; set; }
public List<APIKeyData> APIKeys { get; set; } public List<APIKeyData> APIKeys { get; set; }
public DateTimeOffset? Created { get; set; } public DateTimeOffset? Created { get; set; }
public string DisabledNotifications { get; set; }
} }
} }

View file

@ -0,0 +1,26 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20201015151438_AddDisabledNotificationsToUser")]
public partial class AddDisabledNotificationsToUser : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DisabledNotifications",
table: "AspNetUsers",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DisabledNotifications",
table: "AspNetUsers");
}
}
}

View file

@ -111,6 +111,9 @@ namespace BTCPayServer.Migrations
b.Property<DateTimeOffset?>("Created") b.Property<DateTimeOffset?>("Created")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("DisabledNotifications")
.HasColumnType("TEXT");
b.Property<string>("Email") b.Property<string>("Email")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasMaxLength(256); .HasMaxLength(256);

View file

@ -1,4 +1,7 @@
@inject LinkGenerator linkGenerator @inject LinkGenerator linkGenerator
@inject UserManager<ApplicationUser> UserManager
@inject CssThemeManager CssThemeManager
@using BTCPayServer.HostedServices
@model BTCPayServer.Components.NotificationsDropdown.NotificationSummaryViewModel @model BTCPayServer.Components.NotificationsDropdown.NotificationSummaryViewModel
@if (Model.UnseenCount > 0) @if (Model.UnseenCount > 0)
@ -32,36 +35,47 @@ else
</a> </a>
</li> </li>
} }
<script type="text/javascript"> @{
var disabled = CssThemeManager.Policies.DisableInstantNotifications;
if (!disabled)
{
var user = await UserManager.GetUserAsync(User);
disabled = user.DisabledNotifications == "all";
}
}
@if (!disabled)
{
var supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2; <script type="text/javascript">
var supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;
if (supportsWebSockets) { if (supportsWebSockets) {
var loc = window.location, ws_uri; var loc = window.location, ws_uri;
if (loc.protocol === "https:") { if (loc.protocol === "https:") {
ws_uri = "wss:"; ws_uri = "wss:";
} else { } else {
ws_uri = "ws:"; ws_uri = "ws:";
} }
ws_uri += "//" + loc.host; ws_uri += "//" + loc.host;
ws_uri += "@linkGenerator.GetPathByAction("SubscribeUpdates", "Notifications")"; ws_uri += "@linkGenerator.GetPathByAction("SubscribeUpdates", "Notifications")";
var newDataEndpoint = "@linkGenerator.GetPathByAction("GetNotificationDropdownUI", "Notifications")"; var newDataEndpoint = "@linkGenerator.GetPathByAction("GetNotificationDropdownUI", "Notifications")";
try { try {
socket = new WebSocket(ws_uri); socket = new WebSocket(ws_uri);
socket.onmessage = function (e) { socket.onmessage = function (e) {
$.get(newDataEndpoint, function(data){ $.get(newDataEndpoint, function(data){
$("#notifications-nav-item").replaceWith($(data)); $("#notifications-nav-item").replaceWith($(data));
}); });
}; };
socket.onerror = function (e) { socket.onerror = function (e) {
console.error("Error while connecting to websocket for notifications (callback)", e); console.error("Error while connecting to websocket for notifications (callback)", e);
}; };
} }
catch (e) { catch (e) {
console.error("Error while connecting to websocket for notifications", e); console.error("Error while connecting to websocket for notifications", e);
}
} }
</script>
} }
</script>

View file

@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Contracts;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Controllers
{
public partial class ManageController
{
[HttpGet("notifications")]
public async Task<IActionResult> NotificationSettings([FromServices] IEnumerable<INotificationHandler> notificationHandlers)
{
var user = await _userManager.GetUserAsync(User);
if (user.DisabledNotifications == "all")
{
return View(new NotificationSettingsViewModel() {All = true});
}
var disabledNotifications =
user.DisabledNotifications?.Split(';', StringSplitOptions.RemoveEmptyEntries)?.ToList() ??
new List<string>();
var notifications = notificationHandlers.SelectMany(handler => handler.Meta.Select(tuple =>
new SelectListItem(tuple.name, tuple.identifier,
disabledNotifications.Contains(tuple.identifier, StringComparer.InvariantCultureIgnoreCase))))
.ToList();
return View(new NotificationSettingsViewModel() {DisabledNotifications = notifications});
}
[HttpPost("notifications")]
public async Task<IActionResult> NotificationSettings(NotificationSettingsViewModel vm, string command)
{
var user = await _userManager.GetUserAsync(User);
if (command == "disable-all")
{
user.DisabledNotifications = "all";
}
else if (command == "enable-all")
{
user.DisabledNotifications = "";
}
else if (command == "update")
{
var disabled = vm.DisabledNotifications.Where(item => item.Selected).Select(item => item.Value)
.ToArray();
user.DisabledNotifications = disabled.Any() is true
? string.Join(';', disabled) + ";"
: string.Empty;
}
await _userManager.UpdateAsync(user);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Updated successfully.", Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction("NotificationSettings");
}
public class NotificationSettingsViewModel
{
public bool All { get; set; }
public List<SelectListItem> DisabledNotifications { get; set; }
}
}
}

View file

@ -89,11 +89,11 @@ namespace BTCPayServer.Controllers
} }
#if DEBUG #if DEBUG
[HttpGet] [HttpGet]
public async Task<IActionResult> GenerateJunk(int x = 100) public async Task<IActionResult> GenerateJunk(int x = 100, bool admin=true)
{ {
for (int i = 0; i < x; i++) for (int i = 0; i < x; i++)
{ {
await _notificationSender.SendNotification(new AdminScope(), new JunkNotification()); await _notificationSender.SendNotification(admin? (NotificationScope) new AdminScope(): new UserScope(_userManager.GetUserId(User)), new JunkNotification());
} }
return RedirectToAction("Index"); return RedirectToAction("Index");

View file

@ -9,8 +9,9 @@ using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs namespace BTCPayServer.Services.Notifications.Blobs
{ {
internal class InvoiceEventNotification internal class InvoiceEventNotification:BaseNotification
{ {
private const string TYPE = "invoicestate";
internal class Handler : NotificationHandler<InvoiceEventNotification> internal class Handler : NotificationHandler<InvoiceEventNotification>
{ {
private readonly LinkGenerator _linkGenerator; private readonly LinkGenerator _linkGenerator;
@ -22,7 +23,16 @@ namespace BTCPayServer.Services.Notifications.Blobs
_options = options; _options = options;
} }
public override string NotificationType => "invoicestate"; public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "All invoice updates"),}
.Concat(TextMapping.Select(pair => ($"{TYPE}_{pair.Key}", $"Invoice {pair.Value}"))).ToArray();
}
}
internal static Dictionary<string, string> TextMapping = new Dictionary<string, string>() internal static Dictionary<string, string> TextMapping = new Dictionary<string, string>()
{ {
@ -65,5 +75,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
public string InvoiceId { get; set; } public string InvoiceId { get; set; }
public string Event { get; set; } public string Event { get; set; }
public override string Identifier => $"{TYPE}_{Event}";
public override string NotificationType => TYPE;
} }
} }

View file

@ -1,19 +1,31 @@
#if DEBUG #if DEBUG
using System.Data;
using BTCPayServer.Contracts; using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Notifications.Blobs namespace BTCPayServer.Services.Notifications.Blobs
{ {
internal class JunkNotification internal class JunkNotification: BaseNotification
{ {
private const string TYPE = "junk";
internal class Handler : NotificationHandler<JunkNotification> internal class Handler : NotificationHandler<JunkNotification>
{ {
public override string NotificationType => "junk"; public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "Junk")};
}
}
protected override void FillViewModel(JunkNotification notification, NotificationViewModel vm) protected override void FillViewModel(JunkNotification notification, NotificationViewModel vm)
{ {
vm.Body = $"All your junk r belong to us!"; vm.Body = $"All your junk r belong to us!";
} }
} }
public override string Identifier => NotificationType;
public override string NotificationType => TYPE;
} }
} }
#endif #endif

View file

@ -3,11 +3,20 @@ using BTCPayServer.Models.NotificationViewModels;
namespace BTCPayServer.Services.Notifications.Blobs namespace BTCPayServer.Services.Notifications.Blobs
{ {
internal class NewVersionNotification internal class NewVersionNotification:BaseNotification
{ {
private const string TYPE = "newversion";
internal class Handler : NotificationHandler<NewVersionNotification> internal class Handler : NotificationHandler<NewVersionNotification>
{ {
public override string NotificationType => "newversion"; public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "New version")};
}
}
protected override void FillViewModel(NewVersionNotification notification, NotificationViewModel vm) protected override void FillViewModel(NewVersionNotification notification, NotificationViewModel vm)
{ {
vm.Body = $"New version {notification.Version} released!"; vm.Body = $"New version {notification.Version} released!";
@ -23,5 +32,7 @@ namespace BTCPayServer.Services.Notifications.Blobs
Version = version; Version = version;
} }
public string Version { get; set; } public string Version { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
} }
} }

View file

@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs namespace BTCPayServer.Services.Notifications.Blobs
{ {
public class PayoutNotification public class PayoutNotification : BaseNotification
{ {
private const string TYPE = "payout";
internal class Handler : NotificationHandler<PayoutNotification> internal class Handler : NotificationHandler<PayoutNotification>
{ {
private readonly LinkGenerator _linkGenerator; private readonly LinkGenerator _linkGenerator;
@ -18,18 +20,30 @@ namespace BTCPayServer.Services.Notifications.Blobs
_linkGenerator = linkGenerator; _linkGenerator = linkGenerator;
_options = options; _options = options;
} }
public override string NotificationType => "payout";
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] {(TYPE, "Payouts")};
}
}
protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm) protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm)
{ {
vm.Body = "A new payout is awaiting for approval"; vm.Body = "A new payout is awaiting for approval";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(WalletsController.Payouts), vm.ActionLink = _linkGenerator.GetPathByAction(nameof(WalletsController.Payouts),
"Wallets", "Wallets",
new { walletId = new WalletId(notification.StoreId, notification.PaymentMethod) }, _options.RootPath); new {walletId = new WalletId(notification.StoreId, notification.PaymentMethod)}, _options.RootPath);
} }
} }
public string PayoutId { get; set; } public string PayoutId { get; set; }
public string StoreId { get; set; } public string StoreId { get; set; }
public string PaymentMethod { get; set; } public string PaymentMethod { get; set; }
public string Currency { get; set; } public string Currency { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
} }
} }

View file

@ -1,6 +1,5 @@
using System; using System;
using BTCPayServer.Contracts; using BTCPayServer.Contracts;
using BTCPayServer.Models.NotificationViewModels;
namespace BTCPayServer.Services.Notifications namespace BTCPayServer.Services.Notifications
{ {
@ -9,6 +8,8 @@ namespace BTCPayServer.Services.Notifications
{ {
public abstract string NotificationType { get; } public abstract string NotificationType { get; }
Type INotificationHandler.NotificationBlobType => typeof(TNotification); Type INotificationHandler.NotificationBlobType => typeof(TNotification);
public abstract (string identifier, string name)[] Meta { get; }
void INotificationHandler.FillViewModel(object notification, NotificationViewModel vm) void INotificationHandler.FillViewModel(object notification, NotificationViewModel vm)
{ {
FillViewModel((TNotification)notification, vm); FillViewModel((TNotification)notification, vm);

View file

@ -21,7 +21,6 @@ namespace BTCPayServer.Services.Notifications
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
private readonly Dictionary<string, INotificationHandler> _handlersByNotificationType; private readonly Dictionary<string, INotificationHandler> _handlersByNotificationType;
private readonly Dictionary<Type, INotificationHandler> _handlersByBlobType;
public NotificationManager(ApplicationDbContextFactory factory, UserManager<ApplicationUser> userManager, public NotificationManager(ApplicationDbContextFactory factory, UserManager<ApplicationUser> userManager,
IMemoryCache memoryCache, IEnumerable<INotificationHandler> handlers, EventAggregator eventAggregator) IMemoryCache memoryCache, IEnumerable<INotificationHandler> handlers, EventAggregator eventAggregator)
@ -31,7 +30,6 @@ namespace BTCPayServer.Services.Notifications
_memoryCache = memoryCache; _memoryCache = memoryCache;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType); _handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType);
_handlersByBlobType = handlers.ToDictionary(h => h.NotificationBlobType);
} }
private const int _cacheExpiryMs = 5000; private const int _cacheExpiryMs = 5000;
@ -118,12 +116,5 @@ namespace BTCPayServer.Services.Notifications
return h; return h;
throw new InvalidOperationException($"No INotificationHandler found for {notificationId}"); throw new InvalidOperationException($"No INotificationHandler found for {notificationId}");
} }
public INotificationHandler GetHandler(Type blobType)
{
if (_handlersByBlobType.TryGetValue(blobType, out var h))
return h;
throw new InvalidOperationException($"No INotificationHandler found for {blobType.Name}");
}
} }
} }

View file

@ -18,6 +18,16 @@ namespace BTCPayServer.Services.Notifications
} }
public string StoreId { get; } public string StoreId { get; }
} }
public class UserScope : NotificationScope
{
public UserScope(string userId)
{
if (userId == null)
throw new ArgumentNullException(nameof(userId));
UserId = userId;
}
public string UserId { get; }
}
public interface NotificationScope public interface NotificationScope
{ {

View file

@ -1,8 +1,11 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Contracts;
using BTCPayServer.Data; using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications namespace BTCPayServer.Services.Notifications
@ -24,13 +27,13 @@ namespace BTCPayServer.Services.Notifications
_notificationManager = notificationManager; _notificationManager = notificationManager;
} }
public async Task SendNotification(NotificationScope scope, object notification) public async Task SendNotification(NotificationScope scope, BaseNotification notification)
{ {
if (scope == null) if (scope == null)
throw new ArgumentNullException(nameof(scope)); throw new ArgumentNullException(nameof(scope));
if (notification == null) if (notification == null)
throw new ArgumentNullException(nameof(notification)); throw new ArgumentNullException(nameof(notification));
var users = await GetUsers(scope); var users = await GetUsers(scope, notification.Identifier);
using (var db = _contextFactory.CreateContext()) using (var db = _contextFactory.CreateContext())
{ {
foreach (var uid in users) foreach (var uid in users)
@ -41,7 +44,7 @@ namespace BTCPayServer.Services.Notifications
Id = Guid.NewGuid().ToString(), Id = Guid.NewGuid().ToString(),
Created = DateTimeOffset.UtcNow, Created = DateTimeOffset.UtcNow,
ApplicationUserId = uid, ApplicationUserId = uid,
NotificationType = _notificationManager.GetHandler(notification.GetType()).NotificationType, NotificationType = notification.NotificationType,
Blob = ZipUtils.Zip(obj), Blob = ZipUtils.Zip(obj),
Seen = false Seen = false
}; };
@ -55,22 +58,47 @@ namespace BTCPayServer.Services.Notifications
} }
} }
private async Task<string[]> GetUsers(NotificationScope scope) private async Task<string[]> GetUsers(NotificationScope scope, string notificationIdentifier)
{ {
if (scope is AdminScope) await using var ctx = _contextFactory.CreateContext();
var split = notificationIdentifier.Split('_', StringSplitOptions.None);
var terms = new List<string>();
foreach (var t in split)
{ {
var admins = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin); terms.Add(terms.Any() ? $"{terms.Last().TrimEnd(';')}_{t};" : $"{t};");
return admins.Select(a => a.Id).ToArray();
} }
if (scope is StoreScope s) IQueryable<ApplicationUser> query;
switch (scope)
{ {
using var ctx = _contextFactory.CreateContext(); case AdminScope _:
return ctx.UserStore {
.Where(u => u.StoreDataId == s.StoreId) query = _userManager.GetUsersInRoleAsync(Roles.ServerAdmin).Result.AsQueryable();
.Select(u => u.ApplicationUserId)
.ToArray(); break;
}
case StoreScope s:
query = ctx.UserStore
.Include(store => store.ApplicationUser)
.Where(u => u.StoreDataId == s.StoreId)
.Select(u => u.ApplicationUser);
break;
case UserScope userScope:
query = ctx.Users
.Where(user => user.Id == userScope.UserId);
break;
default:
throw new NotSupportedException("Notification scope not supported");
} }
throw new NotSupportedException("Notification scope not supported"); query = query.Where(store => store.DisabledNotifications != "all");
foreach (string term in terms)
{
query = query.Where(user => user.DisabledNotifications == null || !user.DisabledNotifications.Contains(term));
}
return query.Select(user => user.Id).ToArray();
} }
} }
} }

View file

@ -25,7 +25,9 @@ namespace BTCPayServer.Services
[Display(Name = "Allow non-admins to import their hot wallets to the node wallet")] [Display(Name = "Allow non-admins to import their hot wallets to the node wallet")]
public bool AllowHotWalletRPCImportForAll { get; set; } public bool AllowHotWalletRPCImportForAll { get; set; }
[Display(Name = "Check releases on GitHub and alert when new BTCPayServer version is available")] [Display(Name = "Check releases on GitHub and alert when new BTCPayServer version is available")]
public bool CheckForNewVersions { get; set; } public bool CheckForNewVersions { get; set; }
[Display(Name = "Disable notifications automatically showing (no websockets)")]
public bool DisableInstantNotifications { get; set; }
[Display(Name = "Display app on website root")] [Display(Name = "Display app on website root")]
public string RootAppId { get; set; } public string RootAppId { get; set; }

View file

@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Manage
{ {
public enum ManageNavPages public enum ManageNavPages
{ {
Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys Index, ChangePassword, TwoFactorAuthentication, U2F, APIKeys, Notifications
} }
} }

View file

@ -0,0 +1,45 @@
@using BTCPayServer.Contracts
@model BTCPayServer.Controllers.ManageController.NotificationSettingsViewModel
@inject IEnumerable<INotificationHandler> NotificationHandlers
@{
ViewData.SetActivePageAndTitle(ManageNavPages.Notifications, "Notification preferences");
}
<partial name="_StatusMessage"/>
<form method="post" asp-action="NotificationSettings">
@if (Model.All)
{
<div>
All notifications are disabled.
<button type="submit" class="btn btn-primary" name="command" value="enable-all">Enable</button>
</div>
}
else
{
<div class="form-group">
<label> Do not receive notifications for</label>
<div class="card ">
<ul class="list-group list-group-flush">
@for (var index = 0; index < Model.DisabledNotifications.Count; index++)
{
var item = Model.DisabledNotifications[index];
<li class="list-group-item">
<input type="hidden" asp-for="DisabledNotifications[index].Value"/>
<input type="checkbox" asp-for="DisabledNotifications[index].Selected" class="form-check-inline"/>
<label class="mb-0 cursor-pointer" asp-for="DisabledNotifications[index].Selected">
@item.Text
</label>
</li>
}
</ul>
</div>
</div>
<div>
<button type="submit" class="btn btn-secondary" name="command" value="disable-all">Disable all</button>
<button type="submit" class="btn btn-primary" name="command" value="update">Save</button>
</div>
}
</form>

View file

@ -6,5 +6,6 @@
<a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a> <a id="@ManageNavPages.TwoFactorAuthentication.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.TwoFactorAuthentication)" asp-action="TwoFactorAuthentication">Two-factor authentication</a>
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a> <a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a> <a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a>
<a id="@ManageNavPages.Notifications.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Notifications)" asp-action="NotificationSettings">Notifications</a>
</div> </div>

View file

@ -42,6 +42,11 @@
<label asp-for="AllowHotWalletRPCImportForAll" class="form-check-label"></label> <label asp-for="AllowHotWalletRPCImportForAll" class="form-check-label"></label>
<span asp-validation-for="AllowHotWalletRPCImportForAll" class="text-danger"></span> <span asp-validation-for="AllowHotWalletRPCImportForAll" class="text-danger"></span>
</div> </div>
<div class="form-check">
<input asp-for="DisableInstantNotifications" type="checkbox" class="form-check-input"/>
<label asp-for="DisableInstantNotifications" class="form-check-label"></label>
<span asp-validation-for="DisableInstantNotifications" class="text-danger"></span>
</div>
@if (ViewBag.UpdateUrlPresent) @if (ViewBag.UpdateUrlPresent)
{ {
<div class="form-check"> <div class="form-check">