Store settings: Add branding options (#4131)

* Add logo upload

* Add brand color definition

* Cleanups

* Add logo to store selector

* Improve brand color handling

* Update color input

* Add logo dimensions hint

* Fixes

* Fix pattern and warning in js logs for color validation

* Fix condition, add test

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2022-10-17 12:16:29 +02:00 committed by GitHub
parent 9533809631
commit f9f1a22e3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 152 additions and 14 deletions

View file

@ -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();

View file

@ -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;

View file

@ -1,5 +1,10 @@
@inject BTCPayServer.Services.BTCPayServerEnvironment _env
@inject SignInManager<ApplicationUser> _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<ApplicationUser> 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";
<svg xmlns="http://www.w3.org/2000/svg" role="img" alt="BTCPay Server" class="logo"><use href="@logoSrc#small" class="logo-small" /><use href="@logoSrc#large" class="logo-large" /></svg>
@if (_env.NetworkType != NBitcoin.ChainName.Mainnet)
@if (Env.NetworkType != NBitcoin.ChainName.Mainnet)
{
<span class="badge bg-warning ms-1 ms-sm-0" style="font-size:10px;">@_env.NetworkType.ToString()</span>
<span class="badge bg-warning ms-1 ms-sm-0" style="font-size:10px;">@Env.NetworkType.ToString()</span>
}
}
private string StoreName(string title)
@ -37,7 +42,14 @@ else
{
<div id="StoreSelectorDropdown" class="dropdown only-for-js">
<button id="StoreSelectorToggle" class="btn btn-secondary dropdown-toggle rounded-pill px-3 @(Model.CurrentStoreId == null ? "text-secondary" : "")" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<vc:icon symbol="store"/>
@if (!string.IsNullOrEmpty(Model.CurrentStoreLogoFileId))
{
<img class="logo" src="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.CurrentStoreLogoFileId))" alt="@Model.CurrentDisplayName" />
}
else
{
<vc:icon symbol="store"/>
}
<span>@(Model.CurrentStoreId == null ? "Select Store" : Model.CurrentDisplayName)</span>
<vc:icon symbol="caret-down"/>
</button>
@ -60,7 +72,7 @@ else
</ul>
</div>
}
else if (_signInManager.IsSignedIn(User))
else if (SignInManager.IsSignedIn(User))
{
<a asp-controller="UIUserStores" asp-action="CreateStore" class="btn btn-primary w-100 rounded-pill" id="StoreSelectorCreate">Create Store</a>
}

View file

@ -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);

View file

@ -6,6 +6,7 @@ namespace BTCPayServer.Components.StoreSelector
{
public List<StoreSelectorOption> Options { get; set; }
public string CurrentStoreId { get; set; }
public string CurrentStoreLogoFileId { get; set; }
public string CurrentDisplayName { get; set; }
public bool CurrentStoreIsOwner { get; set; }
}

View file

@ -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<ExternalServicesOptions> 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<ExternalServicesOptions> _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))
{

View file

@ -191,6 +191,8 @@ namespace BTCPayServer.Data
public TimeSpan RefundBOLT11Expiration { get; set; }
public List<UIStoresController.StoreEmailRule> EmailRules { get; set; }
public string LogoFileId { get; set; }
public string BrandColor { get; set; }
public IPaymentFilter GetExcludedPaymentMethods()
{

View file

@ -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")]

View file

@ -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();
}
<div class="row">
@ -11,7 +16,7 @@
{
<div asp-validation-summary="All" class="text-danger"></div>
}
<form method="post">
<form method="post" enctype="multipart/form-data">
<h3 class="mb-3">General</h3>
<div class="form-group">
<label asp-for="Id" class="form-label"></label>
@ -28,6 +33,35 @@
<span asp-validation-for="StoreWebsite" class="text-danger"></span>
</div>
<h3 class="mb-3">Branding</h3>
<div class="form-group">
<label asp-for="LogoFile" class="form-label"></label>
@if (canUpload)
{
<div class="d-flex align-items-center">
<input asp-for="LogoFile" type="file" class="form-control flex-grow">
@if (!string.IsNullOrEmpty(Model.LogoFileId))
{
<img src="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId))" alt="@Model.StoreName" class="rounded-circle ms-3" style="width:2.1rem;height:2.1rem;"/>
}
</div>
<p class="form-text text-muted">Please upload an image with square dimension, as it will be displayed in 1:1 format and circular.</p>
}
else
{
<input asp-for="LogoFile" type="file" class="form-control" disabled>
<p class="form-text text-muted">In order to upload a logo, a <a asp-controller="UIServer" asp-action="Files">file storage</a> must be configured.</p>
}
</div>
<div class="form-group">
<label asp-for="BrandColor" class="form-label"></label>
<div class="input-group">
<input id="BrandColorInput" class="form-control form-control-color flex-grow-0" type="color" style="width:3rem" aria-describedby="BrandColorValue" value="@Model.BrandColor" />
<input asp-for="BrandColor" class="form-control form-control-color flex-grow-0 font-monospace" pattern="@ColorPalette.Pattern" style="width:5.5rem" />
</div>
<span asp-validation-for="BrandColor" class="text-danger"></span>
</div>
<h3 class="mt-5 mb-3">Payment</h3>
<div class="form-group">
<label asp-for="DefaultCurrency" class="form-label"></label>
@ -101,5 +135,19 @@
<partial name="_Confirm" model="@(new ConfirmModel("Delete store", "The store will be permanently deleted. This action will also delete all invoices, apps and data associated with the store.", "Delete"))" />
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
<partial name="_ValidationScriptsPartial"/>
<script>
(() => {
const $colorValue = document.getElementById('BrandColor');
const $colorInput = document.getElementById('BrandColorInput');
delegate('change', '#BrandColor', e => {
const value = e.target.value;
if (value.match(@Safe.Json(@ColorPalette.Pattern)))
$colorInput.value = value;
});
delegate('change', '#BrandColorInput', e => {
$colorValue.value = e.target.value;
});
})();
</script>
}

View file

@ -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);