diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index bea81de8b..7e0ab046b 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -644,15 +644,26 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("enable-pay-button")).Click(); s.Driver.FindElement(By.Id("disable-pay-button")).Click(); s.FindAlertMessage(); - s.GoToStore(StoreNavPages.General); + s.GoToStore(); Assert.False(s.Driver.FindElement(By.Id("AnyoneCanCreateInvoice")).Selected); s.Driver.SetCheckbox(By.Id("AnyoneCanCreateInvoice"), true); s.Driver.FindElement(By.Id("Save")).Click(); s.FindAlertMessage(); Assert.True(s.Driver.FindElement(By.Id("AnyoneCanCreateInvoice")).Selected); + // Store settings: Set and unset brand color + s.GoToStore(); + s.Driver.FindElement(By.Id("BrandColor")).SendKeys("#f7931a"); + s.Driver.FindElement(By.Id("Save")).Click(); + Assert.Contains("Store successfully updated", s.FindAlertMessage().Text); + Assert.Equal("#f7931a", s.Driver.FindElement(By.Id("BrandColor")).GetAttribute("value")); + s.Driver.FindElement(By.Id("BrandColor")).Clear(); + s.Driver.FindElement(By.Id("Save")).Click(); + Assert.Contains("Store successfully updated", s.FindAlertMessage().Text); + Assert.Equal(string.Empty, s.Driver.FindElement(By.Id("BrandColor")).GetAttribute("value")); + // Alice should be able to delete the store - s.GoToStore(StoreNavPages.General); + s.GoToStore(); s.Driver.FindElement(By.Id("DeleteStore")).Click(); s.Driver.WaitForElement(By.Id("ConfirmInput")).SendKeys("DELETE"); s.Driver.FindElement(By.Id("ConfirmContinue")).Click(); diff --git a/BTCPayServer/ColorPalette.cs b/BTCPayServer/ColorPalette.cs index b8c7e4efb..78cc94895 100644 --- a/BTCPayServer/ColorPalette.cs +++ b/BTCPayServer/ColorPalette.cs @@ -1,12 +1,18 @@ using System; using System.Drawing; using System.Text; +using System.Text.RegularExpressions; using NBitcoin.Crypto; namespace BTCPayServer { public class ColorPalette { + public const string Pattern = "^#[0-9a-fA-F]{6}$"; + public static bool IsValid(string color) + { + return Regex.Match(color, Pattern).Success; + } public string TextColor(string bgColor) { int nThreshold = 105; diff --git a/BTCPayServer/Components/StoreSelector/Default.cshtml b/BTCPayServer/Components/StoreSelector/Default.cshtml index eb42bde7f..bb6858cc2 100644 --- a/BTCPayServer/Components/StoreSelector/Default.cshtml +++ b/BTCPayServer/Components/StoreSelector/Default.cshtml @@ -1,5 +1,10 @@ -@inject BTCPayServer.Services.BTCPayServerEnvironment _env -@inject SignInManager _signInManager +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Abstractions.TagHelpers +@using BTCPayServer.Abstractions.Contracts +@inject BTCPayServer.Services.BTCPayServerEnvironment Env +@inject SignInManager SignInManager +@inject IFileService FileService @model BTCPayServer.Components.StoreSelector.StoreSelectorViewModel @functions { @* ReSharper disable once CSharpWarnings::CS1998 *@ @@ -8,9 +13,9 @@ { var logoSrc = $"{ViewContext.HttpContext.Request.PathBase}/img/logo.svg"; - @if (_env.NetworkType != NBitcoin.ChainName.Mainnet) + @if (Env.NetworkType != NBitcoin.ChainName.Mainnet) { - @_env.NetworkType.ToString() + @Env.NetworkType.ToString() } } private string StoreName(string title) @@ -37,7 +42,14 @@ else { } - else if (_signInManager.IsSignedIn(User)) + else if (SignInManager.IsSignedIn(User)) { Create Store } diff --git a/BTCPayServer/Components/StoreSelector/StoreSelector.cs b/BTCPayServer/Components/StoreSelector/StoreSelector.cs index 2750b8c42..4c4d6822f 100644 --- a/BTCPayServer/Components/StoreSelector/StoreSelector.cs +++ b/BTCPayServer/Components/StoreSelector/StoreSelector.cs @@ -50,12 +50,15 @@ namespace BTCPayServer.Components.StoreSelector .OrderBy(s => s.Text) .ToList(); + var blob = currentStore?.GetStoreBlob(); + var vm = new StoreSelectorViewModel { Options = options, CurrentStoreId = currentStore?.Id, CurrentDisplayName = currentStore?.StoreName, - CurrentStoreIsOwner = currentStore?.Role == StoreRoles.Owner + CurrentStoreIsOwner = currentStore?.Role == StoreRoles.Owner, + CurrentStoreLogoFileId = blob?.LogoFileId }; return View(vm); diff --git a/BTCPayServer/Components/StoreSelector/StoreSelectorViewModel.cs b/BTCPayServer/Components/StoreSelector/StoreSelectorViewModel.cs index 349d3013d..6ba4a75a7 100644 --- a/BTCPayServer/Components/StoreSelector/StoreSelectorViewModel.cs +++ b/BTCPayServer/Components/StoreSelector/StoreSelectorViewModel.cs @@ -6,6 +6,7 @@ namespace BTCPayServer.Components.StoreSelector { public List Options { get; set; } public string CurrentStoreId { get; set; } + public string CurrentStoreLogoFileId { get; set; } public string CurrentDisplayName { get; set; } public bool CurrentStoreIsOwner { get; set; } } diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index e49e303e0..62a7eb578 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Models; using BTCPayServer.Client; using BTCPayServer.Configuration; @@ -60,6 +61,7 @@ namespace BTCPayServer.Controllers IAuthorizationService authorizationService, EventAggregator eventAggregator, AppService appService, + IFileService fileService, WebhookSender webhookNotificationManager, IDataProtectionProvider dataProtector, IOptions externalServiceOptions) @@ -75,6 +77,7 @@ namespace BTCPayServer.Controllers _policiesSettings = policiesSettings; _authorizationService = authorizationService; _appService = appService; + _fileService = fileService; DataProtector = dataProtector.CreateProtector("ConfigProtector"); WebhookNotificationManager = webhookNotificationManager; _EventAggregator = eventAggregator; @@ -102,6 +105,7 @@ namespace BTCPayServer.Controllers private readonly PoliciesSettings _policiesSettings; private readonly IAuthorizationService _authorizationService; private readonly AppService _appService; + private readonly IFileService _fileService; private readonly EventAggregator _EventAggregator; private readonly IOptions _externalServiceOptions; @@ -593,6 +597,8 @@ namespace BTCPayServer.Controllers Id = store.Id, StoreName = store.StoreName, StoreWebsite = store.StoreWebsite, + LogoFileId = storeBlob.LogoFileId, + BrandColor = storeBlob.BrandColor, NetworkFeeMode = storeBlob.NetworkFeeMode, AnyoneCanCreateInvoice = storeBlob.AnyoneCanInvoice, PaymentTolerance = storeBlob.PaymentTolerance, @@ -620,7 +626,7 @@ namespace BTCPayServer.Controllers needUpdate = true; CurrentStore.StoreWebsite = model.StoreWebsite; } - + var blob = CurrentStore.GetStoreBlob(); blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice; blob.NetworkFeeMode = model.NetworkFeeMode; @@ -628,6 +634,41 @@ namespace BTCPayServer.Controllers blob.DefaultCurrency = model.DefaultCurrency; blob.InvoiceExpiration = TimeSpan.FromMinutes(model.InvoiceExpiration); blob.RefundBOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration); + if (!string.IsNullOrEmpty(model.BrandColor) && !ColorPalette.IsValid(model.BrandColor)) + { + ModelState.AddModelError(nameof(model.BrandColor), "Invalid color"); + return View(model); + } + blob.BrandColor = model.BrandColor; + + if (model.LogoFile != null) + { + if (model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) + { + var userId = GetUserId(); + + // delete existing image + if (!string.IsNullOrEmpty(blob.LogoFileId)) + { + await _fileService.RemoveFile(blob.LogoFileId, userId); + } + + // add new image + try + { + var storedFile = await _fileService.AddFile(model.LogoFile, userId); + blob.LogoFileId = storedFile.Id; + } + catch (Exception e) + { + TempData[WellKnownTempData.ErrorMessage] = $"Could not save logo: {e.Message}"; + } + } + else + { + TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image"; + } + } if (CurrentStore.SetStoreBlob(blob)) { diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index e3c64dfcc..724f9417a 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -191,6 +191,8 @@ namespace BTCPayServer.Data public TimeSpan RefundBOLT11Expiration { get; set; } public List EmailRules { get; set; } + public string LogoFileId { get; set; } + public string BrandColor { get; set; } public IPaymentFilter GetExcludedPaymentMethods() { diff --git a/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs b/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs index 1906ccd6c..fe943f272 100644 --- a/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/GeneralSettingsViewModel.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using BTCPayServer.Client.Models; using BTCPayServer.Validation; +using Microsoft.AspNetCore.Http; namespace BTCPayServer.Models.StoreViewModels { @@ -21,6 +22,13 @@ namespace BTCPayServer.Models.StoreViewModels [MaxLength(500)] public string StoreWebsite { get; set; } + [Display(Name = "Logo")] + public IFormFile LogoFile { get; set; } + public string LogoFileId { get; set; } + + [Display(Name = "Brand Color")] + public string BrandColor { get; set; } + public bool CanDelete { get; set; } [Display(Name = "Allow anyone to create invoice")] diff --git a/BTCPayServer/Views/UIStores/GeneralSettings.cshtml b/BTCPayServer/Views/UIStores/GeneralSettings.cshtml index fd9d9d185..c0d127a27 100644 --- a/BTCPayServer/Views/UIStores/GeneralSettings.cshtml +++ b/BTCPayServer/Views/UIStores/GeneralSettings.cshtml @@ -1,8 +1,13 @@ @using BTCPayServer.Abstractions.Models +@using BTCPayServer.TagHelpers +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.Contracts +@inject IFileService FileService; @model GeneralSettingsViewModel @{ - Layout = "../Shared/_NavLayout.cshtml"; - ViewData.SetActivePage(StoreNavPages.General, "General", Context.GetStoreData().Id); + Layout = "../Shared/_NavLayout.cshtml"; + ViewData.SetActivePage(StoreNavPages.General, "General", Context.GetStoreData().Id); + var canUpload = await FileService.IsAvailable(); }
@@ -11,7 +16,7 @@ {
} -
+

General

@@ -28,6 +33,35 @@
+

Branding

+
+ + @if (canUpload) + { +
+ + @if (!string.IsNullOrEmpty(Model.LogoFileId)) + { + @Model.StoreName + } +
+

Please upload an image with square dimension, as it will be displayed in 1:1 format and circular.

+ } + else + { + +

In order to upload a logo, a file storage must be configured.

+ } +
+
+ +
+ + +
+ +
+

Payment

@@ -101,5 +135,19 @@ @section PageFootContent { - + + } diff --git a/BTCPayServer/wwwroot/main/layout.css b/BTCPayServer/wwwroot/main/layout.css index 4fede3e69..c4cce2c91 100644 --- a/BTCPayServer/wwwroot/main/layout.css +++ b/BTCPayServer/wwwroot/main/layout.css @@ -150,16 +150,22 @@ content: none; } +#StoreSelectorToggle .logo, #StoreSelectorToggle .icon { width: 1.5rem; height: 1.5rem; transition: color 0.15s ease-in-out; } +#StoreSelectorToggle .logo, #StoreSelectorToggle .icon.icon-store { margin-right: var(--btcpay-space-s); } +#StoreSelectorToggle .logo { + border-radius: 50%; +} + #StoreSelectorToggle .icon.icon-caret-down { margin-left: auto; color: var(--btcpay-body-text-muted);