diff --git a/BTCPayApp.Core/AsyncDuplicateLock.cs b/BTCPayApp.Core/AsyncDuplicateLock.cs new file mode 100644 index 00000000..b9f9da32 --- /dev/null +++ b/BTCPayApp.Core/AsyncDuplicateLock.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; + +namespace BTCPayApp.Core; + +///from https://stackoverflow.com/a/31194647/275504 +public sealed class AsyncDuplicateLock +{ + private sealed class RefCounted + { + public RefCounted(T value) + { + RefCount = 1; + Value = value; + } + + public int RefCount { get; set; } + public T Value { get; private set; } + } + + private readonly ConcurrentDictionary?> _semaphoreSlims = new(); + + private SemaphoreSlim GetOrCreate(object key) + { + RefCounted? item; + lock (_semaphoreSlims) + { + if (_semaphoreSlims.TryGetValue(key, out item) && item is { }) + { + ++item.RefCount; + } + else + { + item = new RefCounted(new SemaphoreSlim(1, 1)); + _semaphoreSlims[key] = item; + } + } + return item.Value; + } + + // get a lock for a specific key, and wait until it is available + public async Task LockAsync(object key, CancellationToken cancellationToken = default) + { + await GetOrCreate(key).WaitAsync(cancellationToken).ConfigureAwait(false); + return new Releaser(_semaphoreSlims, key); + } + + // get a lock for a specific key if it is available, or return null if it is currently locked + public async Task LockOrBustAsync(object key, CancellationToken cancellationToken = default) + { + var semaphore = GetOrCreate(key); + if (semaphore.CurrentCount == 0) + return null; + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new Releaser(_semaphoreSlims, key); + } + private sealed class Releaser : IDisposable + { + private readonly ConcurrentDictionary?> _semaphoreSlims; + + public Releaser(ConcurrentDictionary?> semaphoreSlims, object key) + { + _semaphoreSlims = semaphoreSlims; + Key = key; + } + + private object Key { get; set; } + + public void Dispose() + { + RefCounted? item; + lock (_semaphoreSlims) + { + if (_semaphoreSlims.TryGetValue(Key, out item) && item is { }) + { + --item.RefCount; + if (item.RefCount == 0) + _semaphoreSlims.TryRemove(Key, out _); + } + } + item?.Value.Release(); + } + } +} diff --git a/BTCPayApp.Core/BTCPayApp.Core.csproj b/BTCPayApp.Core/BTCPayApp.Core.csproj index 4fbb22bc..f7fb00f5 100644 --- a/BTCPayApp.Core/BTCPayApp.Core.csproj +++ b/BTCPayApp.Core/BTCPayApp.Core.csproj @@ -37,4 +37,19 @@ + + + ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Authentication.BearerToken.dll + + + ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Components.Authorization.dll + + + ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Http.Abstractions.dll + + + ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Identity.dll + + + diff --git a/BTCPayApp.Core/BTCPayServerAccount.cs b/BTCPayApp.Core/BTCPayServerAccount.cs new file mode 100644 index 00000000..af422c92 --- /dev/null +++ b/BTCPayApp.Core/BTCPayServerAccount.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace BTCPayApp.Core; + +public class BTCPayServerAccount(string baseUri, string email) +{ + public Uri BaseUri { get; init; } = new (WithTrailingSlash(baseUri)); + public string Email { get; init; } = email; + public string? AccessToken { get; set; } + public string? RefreshToken { get; set; } + public DateTimeOffset? AccessExpiry { get; set; } + + [JsonConstructor] + public BTCPayServerAccount() : this(string.Empty, string.Empty) {} + + public void SetAccess(string accessToken, string refreshToken, DateTimeOffset expiry) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + AccessExpiry = expiry; + } + + public void ClearAccess() + { + AccessToken = null; + RefreshToken = null; + AccessExpiry = null; + } + + private static string WithTrailingSlash(string str) => + str.EndsWith("/", StringComparison.InvariantCulture) ? str : str + "/"; +} + diff --git a/BTCPayApp.Core/BTCPayServerAppApiClient.cs b/BTCPayApp.Core/BTCPayServerAppApiClient.cs new file mode 100644 index 00000000..b7eee39b --- /dev/null +++ b/BTCPayApp.Core/BTCPayServerAppApiClient.cs @@ -0,0 +1,166 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BTCPayApp.Core.Contracts; +using BTCPayApp.Core.Models; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Identity.Data; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayApp.Core; + +public class BTCPayServerAppApiClient(IHttpClientFactory clientFactory, ISecureConfigProvider secureConfigProvider) +{ + private readonly HttpClient _httpClient = clientFactory.CreateClient(); + private readonly string[] _unauthenticatedPaths = ["login", "forgot-password", "reset-password"]; + + private BTCPayServerAccount? Account { get; set; } + + public async Task<(AccessTokenResult? success, string? errorCode)> Login(BTCPayServerAccount account, string password, string? otp = null, CancellationToken? cancellation = default) + { + Account = account; + var payload = new LoginRequest + { + Email = Account.Email, + Password = password, + TwoFactorCode = otp + }; + try + { + var now = DateTimeOffset.Now; + var response = await Post("login", payload, cancellation.GetValueOrDefault()); + var res = await HandleAccessTokenResponse(response!, now); + return (res, null); + } + catch (BTCPayServerClientException e) + { + return (null, e.Message); + } + } + + private async Task<(AccessTokenResult? success, string? errorCode)> Refresh(BTCPayServerAccount account, CancellationToken? cancellation = default) + { + if (string.IsNullOrEmpty(account.RefreshToken)) throw new BTCPayServerClientException(422, "Account or Refresh Token missing"); + + Account = account; + var payload = new RefreshRequest + { + RefreshToken = Account.RefreshToken + }; + try + { + var now = DateTimeOffset.Now; + var response = await Post("refresh", payload, cancellation.GetValueOrDefault()); + var res = await HandleAccessTokenResponse(response!, now); + return (res, null); + } + catch (BTCPayServerClientException e) + { + return (null, e.Message); + } + } + + public async Task GetUserInfo(BTCPayServerAccount account, CancellationToken? cancellation = default) + { + Account = account; + return await Get("info", cancellation.GetValueOrDefault()); + } + + public async Task<(bool success, string? errorCode)> ResetPassword(BTCPayServerAccount account, string? resetCode = null, string? newPassword = null, CancellationToken? cancellation = default) + { + Account = account; + var payload = new ResetPasswordRequest + { + Email = Account.Email, + ResetCode = resetCode ?? string.Empty, + NewPassword = newPassword ?? string.Empty + }; + try + { + var path = string.IsNullOrEmpty(payload.ResetCode) && string.IsNullOrEmpty(payload.NewPassword) + ? "forgot-password" + : "reset-password"; + await Post(path, payload, cancellation.GetValueOrDefault()); + return (true, null); + } + catch (BTCPayServerClientException e) + { + return (false, e.Message); + } + } + + public void Logout() + { + _httpClient.DefaultRequestHeaders.Authorization = null; + } + + private async Task Get(string path, CancellationToken cancellation = default) + { + return await Send(HttpMethod.Get, path, null, cancellation); + } + + private async Task Post(string path, TRequest payload, CancellationToken cancellation = default) + { + return await Send(HttpMethod.Post, path, payload, cancellation); + } + + private async Task Send(HttpMethod method, string path, TRequest? payload, CancellationToken cancellation, bool isRetry = false) + { + if (string.IsNullOrEmpty(Account?.BaseUri.ToString())) throw new BTCPayServerClientException(422, "Account or Server URL missing"); + + var req = new HttpRequestMessage + { + RequestUri = new Uri($"{Account.BaseUri}btcpayapp/{path}"), + Method = method, + Content = payload == null ? null : JsonContent.Create(payload) + }; + req.Headers.Accept.Clear(); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + req.Headers.Add("User-Agent", "BTCPayServerAppApiClient"); + + if (!_unauthenticatedPaths.Contains(path) && !string.IsNullOrEmpty(Account.AccessToken)) + { + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Account.AccessToken); + } + + var res = await _httpClient.SendAsync(req, cancellation); + if (!res.IsSuccessStatusCode) + { + // try refresh and recurse if the token is expired + if (res.StatusCode == HttpStatusCode.Unauthorized && !string.IsNullOrEmpty(Account.RefreshToken) && !isRetry) + { + var (refresh, _) = await Refresh(Account, cancellation); + if (refresh != null) return await Send(method, path, payload, cancellation); + } + // otherwise handle the error response + var problem = await res.Content.ReadFromJsonAsync(cancellationToken: cancellation); + var statusCode = problem?.Status ?? (int)res.StatusCode; + var message = problem?.Detail ?? res.ReasonPhrase; + throw new BTCPayServerClientException(statusCode, message ?? "Request failed"); + } + + if (typeof(TResponse) == typeof(EmptyResponseModel)) + { + return (TResponse)(object)new EmptyResponseModel(); + } + return await res.Content.ReadFromJsonAsync(cancellationToken: cancellation); + } + + private class EmptyRequestModel; + private class EmptyResponseModel; + + private class BTCPayServerClientException(int statusCode, string message) : Exception + { + public int StatusCode { get; init; } = statusCode; + public override string Message => message; + } + + private async Task HandleAccessTokenResponse(AccessTokenResponse response, DateTimeOffset expiryOffset) + { + var expiry = expiryOffset + TimeSpan.FromSeconds(response.ExpiresIn); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(response.TokenType, response.AccessToken); + Account!.SetAccess(response.AccessToken, response.RefreshToken, expiry); + await secureConfigProvider.Set("account", Account); + return new AccessTokenResult(response.AccessToken, response.RefreshToken, expiry); + } +} diff --git a/BTCPayApp.Core/Contracts/ISecureConfigProvider.cs b/BTCPayApp.Core/Contracts/ISecureConfigProvider.cs index 561420d0..d8f09c53 100644 --- a/BTCPayApp.Core/Contracts/ISecureConfigProvider.cs +++ b/BTCPayApp.Core/Contracts/ISecureConfigProvider.cs @@ -1,5 +1,3 @@ namespace BTCPayApp.Core.Contracts; -public interface ISecureConfigProvider : IConfigProvider -{ -} \ No newline at end of file +public interface ISecureConfigProvider : IConfigProvider; diff --git a/BTCPayApp.Core/Models/AccessTokenResult.cs b/BTCPayApp.Core/Models/AccessTokenResult.cs new file mode 100644 index 00000000..b099faa3 --- /dev/null +++ b/BTCPayApp.Core/Models/AccessTokenResult.cs @@ -0,0 +1,8 @@ +namespace BTCPayApp.Core.Models; + +public class AccessTokenResult(string accessToken, string refreshToken, DateTimeOffset expiry) +{ + public string AccessToken { get; init; } = accessToken; + public string RefreshToken { get; init; } = refreshToken; + public DateTimeOffset Expiry { get; init; } = expiry; +} diff --git a/BTCPayApp.Core/Models/AppUserInfoResult.cs b/BTCPayApp.Core/Models/AppUserInfoResult.cs new file mode 100644 index 00000000..03a476a7 --- /dev/null +++ b/BTCPayApp.Core/Models/AppUserInfoResult.cs @@ -0,0 +1,18 @@ +namespace BTCPayApp.Core.Models; + +public class AppUserInfoResult +{ + public string UserId { get; set; } + public string Email { get; set; } + public IEnumerable Roles { get; set; } + public IEnumerable Stores { get; set; } +} + +public class AppUserStoreInfo +{ + public string Id { get; set; } + public string Name { get; set; } + public string RoleId { get; set; } + public bool Archived { get; set; } + public IEnumerable Permissions { get; set; } +} diff --git a/BTCPayApp.Core/StartupExtensions.cs b/BTCPayApp.Core/StartupExtensions.cs index f870b829..f6011834 100644 --- a/BTCPayApp.Core/StartupExtensions.cs +++ b/BTCPayApp.Core/StartupExtensions.cs @@ -13,12 +13,10 @@ public static IServiceCollection ConfigureBTCPayAppCore(this IServiceCollection serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(provider => - provider.GetRequiredService()); - serviceCollection.AddSingleton(provider => - provider.GetRequiredService()); - serviceCollection.AddSingleton(provider => - provider.GetRequiredService()); + serviceCollection.AddSingleton(provider => provider.GetRequiredService()); + serviceCollection.AddSingleton(provider => provider.GetRequiredService()); + serviceCollection.AddSingleton(provider => provider.GetRequiredService()); + return serviceCollection; } -} \ No newline at end of file +} diff --git a/BTCPayApp.Desktop/StartupExtensions.cs b/BTCPayApp.Desktop/StartupExtensions.cs index 2e524408..e5f9c512 100644 --- a/BTCPayApp.Desktop/StartupExtensions.cs +++ b/BTCPayApp.Desktop/StartupExtensions.cs @@ -53,7 +53,8 @@ public DesktopConfigProvider(IDataDirectoryProvider directoryProvider) return default; } var raw = await File.ReadAllTextAsync(dir); - return JsonSerializer.Deserialize(await ReadFromRaw(raw)); + var json = await ReadFromRaw(raw); + return JsonSerializer.Deserialize(json); } protected virtual Task ReadFromRaw(string str) => Task.FromResult(str); diff --git a/BTCPayApp.Maui/wwwroot/index.html b/BTCPayApp.Maui/wwwroot/index.html index fe2ab5b3..bebdf4ea 100644 --- a/BTCPayApp.Maui/wwwroot/index.html +++ b/BTCPayApp.Maui/wwwroot/index.html @@ -8,6 +8,7 @@ + diff --git a/BTCPayApp.Photino/wwwroot/index.html b/BTCPayApp.Photino/wwwroot/index.html index fe2ab5b3..bebdf4ea 100644 --- a/BTCPayApp.Photino/wwwroot/index.html +++ b/BTCPayApp.Photino/wwwroot/index.html @@ -8,6 +8,7 @@ + diff --git a/BTCPayApp.Server/Pages/_Host.cshtml b/BTCPayApp.Server/Pages/_Host.cshtml index eba6b34d..f7decbf0 100644 --- a/BTCPayApp.Server/Pages/_Host.cshtml +++ b/BTCPayApp.Server/Pages/_Host.cshtml @@ -14,6 +14,7 @@ + diff --git a/BTCPayApp.Tests/BTCPayAppTestServer.cs b/BTCPayApp.Tests/BTCPayAppTestServer.cs index b8cd4374..287dd046 100644 --- a/BTCPayApp.Tests/BTCPayAppTestServer.cs +++ b/BTCPayApp.Tests/BTCPayAppTestServer.cs @@ -1,4 +1,9 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Playwright; diff --git a/BTCPayApp.Tests/UnitTest1.cs b/BTCPayApp.Tests/UnitTest1.cs index df1dba22..0601a509 100644 --- a/BTCPayApp.Tests/UnitTest1.cs +++ b/BTCPayApp.Tests/UnitTest1.cs @@ -1,3 +1,4 @@ +using BTCPayApp.UI; using BTCPayApp.UI.Pages; using Xunit.Abstractions; @@ -18,7 +19,7 @@ public async Task TestHomePage() await using var factory = new BTCPayAppTestServer(_testOutputHelper); var page = await (await factory.InitializeAsync()).NewPageAsync(); await page.GotoAsync(factory.ServerAddress); - Assert.EndsWith(Routes.Home, page.Url); + Assert.EndsWith(Routes.Index, page.Url); var carousel = page.Locator("#OnboardingCarousel"); await carousel.Locator("[aria-label='3']").ClickAsync(); diff --git a/BTCPayApp.UI/App.razor b/BTCPayApp.UI/App.razor index 60e96190..778eb3b2 100644 --- a/BTCPayApp.UI/App.razor +++ b/BTCPayApp.UI/App.razor @@ -1,5 +1,7 @@ @using BTCPayApp.UI.Features +@using BTCPayApp.UI.Pages @using BTCPayApp.Core.Contracts +@using Microsoft.AspNetCore.Http @inherits Fluxor.Blazor.Web.Components.FluxorComponent @inject IStateSelection> LoadingStateSelection @inject IDispatcher Dispatcher @@ -9,20 +11,31 @@ - + + + + + Not found - -
This page does not exist.
+ +
@code { + [CascadingParameter] + private Task? AuthenticationState { get; set; } + + [CascadingParameter] + public HttpContext? HttpContext { get; set; } + protected override async Task OnInitializedAsync() { + // UI LoadingStateSelection.Select(state => state.Loading); var state = await ConfigProvider.Get(StateMiddleware.UiStateConfigKey); if (state != null) @@ -32,6 +45,5 @@ Dispatcher.Dispatch(new UIState.ApplyUserTheme(state.SelectedTheme)); } } - await base.OnInitializedAsync(); } } diff --git a/BTCPayApp.UI/AuthStateProvider.cs b/BTCPayApp.UI/AuthStateProvider.cs new file mode 100644 index 00000000..cccd1eee --- /dev/null +++ b/BTCPayApp.UI/AuthStateProvider.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using BTCPayApp.Core; +using Microsoft.AspNetCore.Components.Authorization; + +namespace BTCPayApp.UI; + +public class AuthStateProvider : AuthenticationStateProvider +{ + public BTCPayServerAccount? Account { get; private set; } + + public override Task GetAuthenticationStateAsync() + { + var identity = new ClaimsIdentity(); + if (Account != null) + { + var claims = new[] + { + new Claim(ClaimTypes.Uri, Account.BaseUri.AbsoluteUri), + new Claim(ClaimTypes.Name, Account.Email), + new Claim(ClaimTypes.Email, Account.Email), + new Claim("AccessToken", Account.AccessToken), + new Claim("RefreshToken", Account.RefreshToken) + }; + identity = new ClaimsIdentity(claims, "Bearer"); + } + return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(identity))); + } + + public bool SetAccount(BTCPayServerAccount? account) + { + if (account == null || string.IsNullOrEmpty(account.AccessToken) || string.IsNullOrEmpty(account.RefreshToken)) + { + Logout(); + return false; + } + + Account = account; + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + return true; + } + + public void Logout() + { + Account = null; + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } +} diff --git a/BTCPayApp.UI/BTCPayApp.UI.csproj b/BTCPayApp.UI/BTCPayApp.UI.csproj index e7c2f1c9..1bbad852 100644 --- a/BTCPayApp.UI/BTCPayApp.UI.csproj +++ b/BTCPayApp.UI/BTCPayApp.UI.csproj @@ -19,9 +19,20 @@ + + + + + + ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Http.Abstractions.dll + + + ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\8.0.0\Microsoft.AspNetCore.Identity.dll + + diff --git a/BTCPayApp.UI/Components/QrScanModal.razor b/BTCPayApp.UI/Components/QrScanModal.razor new file mode 100644 index 00000000..ba6ded1b --- /dev/null +++ b/BTCPayApp.UI/Components/QrScanModal.razor @@ -0,0 +1,49 @@ +@inject IJSRuntime JS +@using ReactorBlazorQRCodeScanner + + + +@code { + private QRCodeScannerJsInterop? _qrCodeScannerJsInterop; + + [Parameter] + public Action? OnScan { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + await JS.InvokeVoidAsync("Interop.addModalEvent", DotNetObjectReference.Create(this), "#ScanQrCodeModal", "show", "OnShow"); + await JS.InvokeVoidAsync("Interop.addModalEvent", DotNetObjectReference.Create(this), "#ScanQrCodeModal", "hide", "OnHide"); + } + + [JSInvokable] + public async Task OnShow() + { + if (OnScan == null) return; + _qrCodeScannerJsInterop ??= new QRCodeScannerJsInterop(JS); + await _qrCodeScannerJsInterop.Init(OnScan); + } + + [JSInvokable] + public async Task OnHide() + { + if (_qrCodeScannerJsInterop == null) return; + await _qrCodeScannerJsInterop.StopRecording(); + } +} + + diff --git a/BTCPayApp.UI/Components/QrScanModal.razor.css b/BTCPayApp.UI/Components/QrScanModal.razor.css new file mode 100644 index 00000000..d01e4a5c --- /dev/null +++ b/BTCPayApp.UI/Components/QrScanModal.razor.css @@ -0,0 +1,13 @@ +.modal { + --blur-size: 1rem; + + background: rgba(var(--btcpay-body-bg-rgb), 0.1); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(var(--blur-size)); + -webkit-backdrop-filter: blur(var(--blur-size)); +} + +/* Use deep because it gets rendered via JS and Blazor doesn't get to attach its selector attribute */ +::deep canvas { + margin: calc(var(--btcpay-modal-padding) * -1) 0 0 calc(var(--btcpay-modal-padding) * -1); +} diff --git a/BTCPayApp.UI/Components/RedirectToIndex.razor b/BTCPayApp.UI/Components/RedirectToIndex.razor new file mode 100644 index 00000000..ef55bef9 --- /dev/null +++ b/BTCPayApp.UI/Components/RedirectToIndex.razor @@ -0,0 +1,16 @@ +@inject NavigationManager NavigationManager + +@code { + protected override void OnInitialized() + { + try + { + NavigationManager.NavigateTo(Routes.Index); + } + catch (Exception) + { + // ignored, see https://github.com/dotnet/aspnetcore/issues/53996 + } + base.OnInitialized(); + } +} diff --git a/BTCPayApp.UI/Layout/BaseLayout.razor b/BTCPayApp.UI/Layout/BaseLayout.razor new file mode 100644 index 00000000..bf87284d --- /dev/null +++ b/BTCPayApp.UI/Layout/BaseLayout.razor @@ -0,0 +1,5 @@ +@inherits Fluxor.Blazor.Web.Components.FluxorLayout + +
+ @Body +
diff --git a/BTCPayApp.UI/Layout/BaseLayout.razor.css b/BTCPayApp.UI/Layout/BaseLayout.razor.css new file mode 100644 index 00000000..e69de29b diff --git a/BTCPayApp.UI/Layout/MainLayout.razor b/BTCPayApp.UI/Layout/MainLayout.razor index e83832fb..e2e9f8ea 100644 --- a/BTCPayApp.UI/Layout/MainLayout.razor +++ b/BTCPayApp.UI/Layout/MainLayout.razor @@ -6,7 +6,7 @@ - +
diff --git a/BTCPayApp.UI/Layout/SimpleLayout.razor b/BTCPayApp.UI/Layout/SimpleLayout.razor index 20564d90..ab6f94cc 100644 --- a/BTCPayApp.UI/Layout/SimpleLayout.razor +++ b/BTCPayApp.UI/Layout/SimpleLayout.razor @@ -1,5 +1,10 @@ @inherits Fluxor.Blazor.Web.Components.FluxorLayout -
- @Body -
+
+ +
+ @Body +
+
diff --git a/BTCPayApp.UI/Layout/SimpleLayout.razor.css b/BTCPayApp.UI/Layout/SimpleLayout.razor.css new file mode 100644 index 00000000..d409d3f0 --- /dev/null +++ b/BTCPayApp.UI/Layout/SimpleLayout.razor.css @@ -0,0 +1,13 @@ +.container { + --logo-height: 5rem; + + max-width: 24rem; +} +.logo { + height: var(--logo-height); +} +@media (min-height: 65rem) { + .logo { + margin-top: calc(var(--logo-height) * -1); + } +} diff --git a/BTCPayApp.UI/Models/UserAccount.cs b/BTCPayApp.UI/Models/UserAccount.cs new file mode 100644 index 00000000..5e430f82 --- /dev/null +++ b/BTCPayApp.UI/Models/UserAccount.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; + +namespace BTCPayApp.UI.Models; + +public class UserAccount +{ + public string Id { get; set; } + public string Email { get; set; } + public string Token { get; set; } + public ClaimsPrincipal Principal { get; set; } = new(); +} diff --git a/BTCPayApp.UI/Pages/ConnectPage.razor b/BTCPayApp.UI/Pages/ConnectPage.razor new file mode 100644 index 00000000..efbe7d44 --- /dev/null +++ b/BTCPayApp.UI/Pages/ConnectPage.razor @@ -0,0 +1,56 @@ +@using Microsoft.Extensions.Logging +@attribute [Route(Routes.Connect)] +@attribute [AllowAnonymous] +@layout SimpleLayout +@inject IJSRuntime JS +@inject ILogger Logger +@inject NavigationManager NavigationManager + +Connect to a server + + + +

Connect to a server

+

+ A server is your .. accessed using a unique URL. + Enter or scan your server URL or invite link. +

+
+ +
+ + +
+ +
+ +
+ + + +@code { + ConnectModel Model { get; set; } = new(); + + private void HandleValidSubmit() + { + var loginUri = $"{Routes.Login}?serverUrl={Model.Uri}"; + NavigationManager.NavigateTo(loginUri); + } + + private void OnQrCodeScan(string code) + { + Logger.LogInformation("QrCode = {QrCode}", code); + Model.Uri = code; + StateHasChanged(); + JS.InvokeVoidAsync("Interop.closeModal", "#ScanQrCodeModal"); + } + + private class ConnectModel + { + [Url] + [Required] + public string Uri { get; set; } + } +} diff --git a/BTCPayApp.UI/Pages/ConnectPage.razor.css b/BTCPayApp.UI/Pages/ConnectPage.razor.css new file mode 100644 index 00000000..e69de29b diff --git a/BTCPayApp.UI/Pages/DashboardPage.razor b/BTCPayApp.UI/Pages/DashboardPage.razor new file mode 100644 index 00000000..d59e625a --- /dev/null +++ b/BTCPayApp.UI/Pages/DashboardPage.razor @@ -0,0 +1,106 @@ +@attribute [Route(Routes.Dashboard)] +@using BTCPayApp.UI.Features +@using Microsoft.Extensions.Logging +@using BTCPayApp.Core +@using BTCPayApp.Core.Models +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject BTCPayServerAppApiClient BtcPayServerAppApiClient +@inject ILogger Logger +@inject IState State +@inherits Fluxor.Blazor.Web.Components.FluxorComponent + +BTCPay Server + +@if (IsInitialized && !IsLoading) +{ + @if (!string.IsNullOrEmpty(_errorMessage)) + { +
@_errorMessage
+ } + + +

Hello, @context.User.Identity?.Name!

+ + @if (_userInfo != null) + { +
+ ID: @_userInfo.UserId
+ Email: @_userInfo.Email
+ Roles: @string.Join(", ", _userInfo.Roles) +
+ + if (_userInfo.Stores.Any()) + { +
    + @foreach (var store in _userInfo.Stores) + { +
  • +
    + @store.Name + @store.RoleId + @if (store.Archived) + { + archived + } +
    +
    + + @if (store.Permissions.Any()) + { +
      + @foreach (var permission in store.Permissions) + { +
    • @permission
    • + } +
    + } + else + { +
    No permissions
    + } +
  • + } +
+ } + else + { +

No stores

+ } + } +
+
+ + @*if (State.Value.PairConfig is null && State.Value.WalletConfig is null) + { + + } + else + { + + }*@ +} + +@code { + [CascadingParameter] + private Task? AuthenticationState { get; set; } + + private string? _errorMessage; + private AppUserInfoResult? _userInfo; + + private bool IsInitialized => State.Value.PairConfigRequested && State.Value.WalletConfigRequested; + private bool IsLoading => State.Value.Loading.Contains(RootState.LoadingHandles.PairConfig) || + State.Value.Loading.Contains(RootState.LoadingHandles.WalletConfig); + + protected override async Task OnInitializedAsync() + { + try + { + var account = ((AuthStateProvider)AuthenticationStateProvider).Account; + _userInfo = account != null ? await BtcPayServerAppApiClient.GetUserInfo(account) : null; + } + catch (Exception e) + { + _errorMessage = !string.IsNullOrEmpty(e.InnerException?.Message) ? e.InnerException.Message : e.Message; + } + } +} diff --git a/BTCPayApp.UI/Pages/ErrorPage.razor b/BTCPayApp.UI/Pages/ErrorPage.razor new file mode 100644 index 00000000..64bc0036 --- /dev/null +++ b/BTCPayApp.UI/Pages/ErrorPage.razor @@ -0,0 +1,18 @@ +@attribute [AllowAnonymous] +@layout SimpleLayout + +@Title + +
+

@Title

+

@Message

+ Go Back +
+ +@code { + [Parameter, EditorRequired] + public string Title { get; set; } + + [Parameter, EditorRequired] + public string Message { get; set; } +} diff --git a/BTCPayApp.UI/Pages/ForgotPasswordPage.razor b/BTCPayApp.UI/Pages/ForgotPasswordPage.razor new file mode 100644 index 00000000..15b8ab05 --- /dev/null +++ b/BTCPayApp.UI/Pages/ForgotPasswordPage.razor @@ -0,0 +1,172 @@ +@attribute [Route(Routes.ForgotPassword)] +@attribute [AllowAnonymous] +@layout SimpleLayout +@using Microsoft.Extensions.Logging +@using BTCPayApp.Core +@using BTCPayApp.Core.Contracts +@using BTCPayApp.UI.Util +@inject ILogger Logger +@inject AuthenticationStateProvider AuthStateProvider +@inject BTCPayServerAppApiClient BtcPayServerAppApiClient +@inject ISecureConfigProvider SecureConfigProvider +@inject NavigationManager NavigationManager + +Forgot your password? + + + +

Forgot your password?

+ @if (!string.IsNullOrEmpty(_errorMessage)) + { +
@_errorMessage
+ } else if (!string.IsNullOrEmpty(_successMessage)) + { +
@_successMessage
+ } + + + @if (Model.Mode == ResetPasswordMode.ResetPassword) + { +
+ + + +
+
+ + + +
+
+ + + +
+ } + @if (Model.Mode == ResetPasswordMode.Success) + { + Go To Login + } + else + { + +

+ Back to login +

+ } +
+ +@code { + [Parameter] + [SupplyParameterFromQuery(Name = "serverUrl")] + public string? ServerUrl { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "email")] + public string? Email { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "reset")] + public bool Reset { get; set; } + + [CascadingParameter] + private Task? AuthenticationState { get; set; } + + private ResetPasswordModel Model { get; set; } = new(); + + private string? _errorMessage; + private string? _successMessage; + + protected override async Task OnInitializedAsync() + { + if (!string.IsNullOrEmpty(ServerUrl) || !string.IsNullOrEmpty(Email)) + { + Model.Uri = string.IsNullOrEmpty(ServerUrl) ? null : Uri.UnescapeDataString(ServerUrl); + Model.Email = string.IsNullOrEmpty(Email) ? null : Uri.UnescapeDataString(Email); + } + else + { + // See if we had a previous session/account + var account = await SecureConfigProvider.Get("account"); + Model.Uri = account?.BaseUri.ToString(); + Model.Email = account?.Email; + } + + if (Reset && !string.IsNullOrEmpty(Model.Uri) && !string.IsNullOrEmpty(Model.Email)) + { + Model.Mode = ResetPasswordMode.ResetPassword; + } + } + + public async Task HandleValidSubmit() + { + _errorMessage = _successMessage = null; + + try + { + var account = new BTCPayServerAccount(Model.Uri, Model.Email); + var (success, errorCode) = await BtcPayServerAppApiClient.ResetPassword(account, Model.ResetCode, Model.NewPassword); + if (success) + { + if (Model.Mode == ResetPasswordMode.ResetPassword) + { + Model.Mode = ResetPasswordMode.Success; + _successMessage = "Your password has been reset. You can now login."; + } + else + { + Model.Mode = ResetPasswordMode.ResetPassword; + _successMessage = "You should have received an email with a password reset code."; + } + } + else + { + _errorMessage = string.IsNullOrEmpty(errorCode) || errorCode == "Failed" ? "Invalid password reset attempt." : errorCode; + } + } + catch (Exception e) + { + _errorMessage = !string.IsNullOrEmpty(e.InnerException?.Message) ? e.InnerException.Message : e.Message; + } + } + + public enum ResetPasswordMode + { + ForgotPassword, + ResetPassword, + Success + } + + private class ResetPasswordModel + { + public ResetPasswordMode Mode { get; set; } = ResetPasswordMode.ForgotPassword; + + [Required] + [Url] + public string? Uri { get; set; } + + [Required] + [EmailAddress] + public string? Email { get; set; } + + [RequiredIf(nameof(Mode), ResetPasswordMode.ResetPassword)] + public string? ResetCode { get; set; } + + [DataType(DataType.Password)] + [RequiredIf(nameof(Mode), ResetPasswordMode.ResetPassword)] + public string? NewPassword { get; set; } + + [DataType(DataType.Password)] + [RequiredIf(nameof(Mode), ResetPasswordMode.ResetPassword)] + [Compare("NewPassword", ErrorMessage = "The password and its confirmation do not match.")] + public string? ConfirmPassword { get; set; } + } +} diff --git a/BTCPayApp.UI/Pages/HomePage.razor b/BTCPayApp.UI/Pages/HomePage.razor deleted file mode 100644 index 05c10c56..00000000 --- a/BTCPayApp.UI/Pages/HomePage.razor +++ /dev/null @@ -1,24 +0,0 @@ -@attribute [Route(Routes.Home)] -@using BTCPayApp.UI.Features -@inject IState State -@inherits Fluxor.Blazor.Web.Components.FluxorComponent - -BTCPay Server - -@if (IsInitialized && !IsLoading) -{ - if (State.Value.PairConfig is null && State.Value.WalletConfig is null) - { - - } - else - { - - } -} - -@code { - private bool IsInitialized => State.Value.PairConfigRequested && State.Value.WalletConfigRequested; - private bool IsLoading => State.Value.Loading.Contains(RootState.LoadingHandles.PairConfig) || - State.Value.Loading.Contains(RootState.LoadingHandles.WalletConfig); -} diff --git a/BTCPayApp.UI/Pages/IndexPage.razor b/BTCPayApp.UI/Pages/IndexPage.razor new file mode 100644 index 00000000..014a445e --- /dev/null +++ b/BTCPayApp.UI/Pages/IndexPage.razor @@ -0,0 +1,33 @@ +@attribute [Route(Routes.Index)] +@attribute [AllowAnonymous] +@layout BaseLayout +@using BTCPayApp.Core.Contracts +@using BTCPayApp.Core +@inject NavigationManager NavigationManager +@inject ISecureConfigProvider SecureConfigProvider +@inject AuthenticationStateProvider AuthStateProvider +@inherits Fluxor.Blazor.Web.Components.FluxorComponent + +BTCPay Server + +@code { + [CascadingParameter] + private Task? AuthenticationState { get; set; } + + protected override async Task OnInitializedAsync() + { + // Sign in + var account = await SecureConfigProvider.Get("account"); + ((AuthStateProvider)AuthStateProvider).SetAccount(account); + + var isAuthenticated = AuthenticationState is not null && (await AuthenticationState).User.Identity?.IsAuthenticated is true; + if (isAuthenticated) + { + NavigationManager.NavigateTo(Routes.Dashboard); + } + else + { + NavigationManager.NavigateTo(account != null ? Routes.Login : Routes.Welcome); + } + } +} diff --git a/BTCPayApp.UI/Pages/LoginPage.razor b/BTCPayApp.UI/Pages/LoginPage.razor new file mode 100644 index 00000000..d0930414 --- /dev/null +++ b/BTCPayApp.UI/Pages/LoginPage.razor @@ -0,0 +1,128 @@ +@attribute [Route(Routes.Login)] +@attribute [AllowAnonymous] +@layout SimpleLayout +@using Microsoft.Extensions.Logging +@using BTCPayApp.Core +@using BTCPayApp.Core.Contracts +@using BTCPayApp.UI.Util +@inject ILogger Logger +@inject AuthenticationStateProvider AuthStateProvider +@inject BTCPayServerAppApiClient BtcPayServerAppApiClient +@inject ISecureConfigProvider SecureConfigProvider +@inject NavigationManager NavigationManager + +Login to a server + + + +

Login to a server

+ @if (!string.IsNullOrEmpty(_errorMessage)) + { +
@_errorMessage
+ } + + + + @if (Model.RequireTwoFactor) + { +
+ + + +
+ } + + +
+ +@code { + [Parameter] + [SupplyParameterFromQuery(Name = "serverUrl")] + public string? ServerUrl { get; set; } + + [CascadingParameter] + private Task? AuthenticationState { get; set; } + + private LoginModel Model { get; set; } = new(); + + private string? _errorMessage; + + protected override async Task OnInitializedAsync() + { + if (!string.IsNullOrEmpty(ServerUrl)) + { + // Parameter passed by ConnectPage + Model.Uri = Uri.UnescapeDataString(ServerUrl); + } + else + { + // See if we had a previous session/account + var account = await SecureConfigProvider.Get("account"); + Model.Uri = account?.BaseUri.ToString(); + Model.Email = account?.Email; + } + } + + public async Task HandleValidSubmit() + { + _errorMessage = null; + + try + { + var account = new BTCPayServerAccount(Model.Uri, Model.Email); + var (access, errorCode) = await BtcPayServerAppApiClient.Login(account, Model.Password, Model.TwoFactorCode); + if (access != null) + { + account.SetAccess(access.AccessToken, access.RefreshToken, access.Expiry); + ((AuthStateProvider)AuthStateProvider).SetAccount(account); + NavigationManager.NavigateTo(Routes.Index); + } + else if (errorCode == "RequiresTwoFactor") + { + Model.RequireTwoFactor = true; + } + else + { + _errorMessage = string.IsNullOrEmpty(errorCode) || errorCode == "Failed" ? "Invalid login attempt." : errorCode; + } + } + catch (Exception e) + { + _errorMessage = !string.IsNullOrEmpty(e.InnerException?.Message) ? e.InnerException.Message : e.Message; + } + } + + private class LoginModel + { + public bool RequireTwoFactor { get; set; } + + [Required] + [Url] + public string? Uri { get; set; } + + [Required] + [EmailAddress] + public string? Email { get; set; } + + [Required] + [DataType(DataType.Password)] + public string? Password { get; set; } + + [RequiredIf(nameof(RequireTwoFactor), true)] + public string? TwoFactorCode { get; set; } + } +} diff --git a/BTCPayApp.UI/Pages/LogoutPage.razor b/BTCPayApp.UI/Pages/LogoutPage.razor new file mode 100644 index 00000000..024db116 --- /dev/null +++ b/BTCPayApp.UI/Pages/LogoutPage.razor @@ -0,0 +1,22 @@ +@attribute [Route(Routes.Logout)] +@using BTCPayApp.Core +@using BTCPayApp.Core.Contracts +@inject AuthenticationStateProvider AuthStateProvider +@inject BTCPayServerAppApiClient BtcPayServerAppApiClient +@inject ISecureConfigProvider SecureConfigProvider + +Logout + +@code { + protected override async Task OnInitializedAsync() + { + BtcPayServerAppApiClient.Logout(); + var account = await SecureConfigProvider.Get("account"); + if (account != null) + { + account.ClearAccess(); + await SecureConfigProvider.Set("account", account); + } + ((AuthStateProvider)AuthStateProvider).Logout(); + } +} diff --git a/BTCPayApp.UI/Pages/PairingSetupPage.razor b/BTCPayApp.UI/Pages/PairingSetupPage.razor index 0ce54cfc..7335727a 100644 --- a/BTCPayApp.UI/Pages/PairingSetupPage.razor +++ b/BTCPayApp.UI/Pages/PairingSetupPage.razor @@ -19,7 +19,7 @@ if (result.PairingResult is not null) { - Dispatcher.Dispatch(new GoAction(Routes.Home)); + Dispatcher.Dispatch(new GoAction(Routes.Index)); } } } diff --git a/BTCPayApp.UI/Pages/PointOfSalePage.razor b/BTCPayApp.UI/Pages/PointOfSalePage.razor index 6fbb8e83..bdb629db 100644 --- a/BTCPayApp.UI/Pages/PointOfSalePage.razor +++ b/BTCPayApp.UI/Pages/PointOfSalePage.razor @@ -1,5 +1,5 @@ @attribute [Route(Routes.PointOfSale)] -@layout SimpleLayout +@layout BaseLayout @using System.Globalization @inherits Fluxor.Blazor.Web.Components.FluxorComponent diff --git a/BTCPayApp.UI/Pages/SettingsPage.razor b/BTCPayApp.UI/Pages/SettingsPage.razor index 406f2494..1e5e1af0 100644 --- a/BTCPayApp.UI/Pages/SettingsPage.razor +++ b/BTCPayApp.UI/Pages/SettingsPage.razor @@ -9,4 +9,3 @@

Settings

- diff --git a/BTCPayApp.UI/Pages/WelcomePage.razor b/BTCPayApp.UI/Pages/WelcomePage.razor new file mode 100644 index 00000000..310ae30f --- /dev/null +++ b/BTCPayApp.UI/Pages/WelcomePage.razor @@ -0,0 +1,14 @@ +@attribute [Route(Routes.Welcome)] +@attribute [AllowAnonymous] +@layout SimpleLayout + +Welcome to BTCPay Server + +
+

Welcome to BTCPay

+

+ BTCPay Server is a self-hosted, open-source Bitcoin payment processor. + It's secure, private, censorship-resistant and free. +

+ Get Started +
diff --git a/BTCPayApp.UI/Pages/WelcomePage.razor.css b/BTCPayApp.UI/Pages/WelcomePage.razor.css new file mode 100644 index 00000000..e69de29b diff --git a/BTCPayApp.UI/Routes.cs b/BTCPayApp.UI/Routes.cs index dc782789..8a8a93c2 100644 --- a/BTCPayApp.UI/Routes.cs +++ b/BTCPayApp.UI/Routes.cs @@ -2,9 +2,19 @@ public static class Routes { - public const string Home = "/"; + // unauthorized + public const string Index = "/"; + public const string Welcome = "/welcome"; + public const string Connect = "/connect"; + public const string Login = "/login"; + public const string ForgotPassword = "/forgot-password"; + + + // authorized + public const string Dashboard = "/dashboard"; public const string Settings = "/settings"; public const string Pair = "/pair"; public const string WalletSetup = "/wallet/setup"; public const string PointOfSale = "/pos"; + public const string Logout = "/logout"; } diff --git a/BTCPayApp.UI/StartupExtensions.cs b/BTCPayApp.UI/StartupExtensions.cs index 2bfc88ed..51920f78 100644 --- a/BTCPayApp.UI/StartupExtensions.cs +++ b/BTCPayApp.UI/StartupExtensions.cs @@ -1,4 +1,6 @@ -using Fluxor; +using BTCPayApp.Core; +using Fluxor; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; namespace BTCPayApp.UI; @@ -6,6 +8,11 @@ public static class StartupExtensions { public static IServiceCollection AddBTCPayAppUIServices(this IServiceCollection serviceCollection) { + serviceCollection.AddOptions(); + serviceCollection.AddAuthorizationCore(); + serviceCollection.AddCascadingAuthenticationState(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddFluxor(options => { options.UseRouting(); diff --git a/BTCPayApp.UI/Util/RequiredIfAttribute.cs b/BTCPayApp.UI/Util/RequiredIfAttribute.cs new file mode 100644 index 00000000..de283598 --- /dev/null +++ b/BTCPayApp.UI/Util/RequiredIfAttribute.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace BTCPayApp.UI.Util; + +public class RequiredIfAttribute(string otherProperty, object targetValue) : ValidationAttribute +{ + protected override ValidationResult IsValid(object? value, ValidationContext validationContext) + { + var otherPropertyValue = validationContext.ObjectType + .GetProperty(otherProperty)? + .GetValue(validationContext.ObjectInstance); + if (otherPropertyValue is null || !otherPropertyValue.Equals(targetValue)) return ValidationResult.Success; + return string.IsNullOrWhiteSpace(value?.ToString()) + ? new ValidationResult(ErrorMessage ?? "This field is required.") + : ValidationResult.Success; + } +} diff --git a/BTCPayApp.UI/_Imports.razor b/BTCPayApp.UI/_Imports.razor index 54bf7820..10134ef8 100644 --- a/BTCPayApp.UI/_Imports.razor +++ b/BTCPayApp.UI/_Imports.razor @@ -1,5 +1,8 @@ @using System.Net.Http +@using System.ComponentModel.DataAnnotations @using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @@ -9,3 +12,6 @@ @using BTCPayApp.UI @using BTCPayApp.UI.Layout @using BTCPayApp.UI.Components + +@* Authorize by default, use "@attribute [AllowAnonymous]" for pages withouth authentication *@ +@attribute [Authorize] diff --git a/BTCPayApp.UI/wwwroot/bootstrap/bootstrap.css b/BTCPayApp.UI/wwwroot/bootstrap/bootstrap.css index d0ab8bd1..f9c8fe8b 100644 --- a/BTCPayApp.UI/wwwroot/bootstrap/bootstrap.css +++ b/BTCPayApp.UI/wwwroot/bootstrap/bootstrap.css @@ -2927,9 +2927,9 @@ textarea.form-control-lg { margin-left: .5em; } -.was-validated .input-group > .form-control:not(:focus):invalid, .input-group > .form-control:not(:focus).is-invalid, .was-validated +.was-validated .input-group > .form-control:not(:focus):invalid, .input-group > .form-control:not(:focus).is-invalid .was-validated, .input-group > .form-select:not(:focus):invalid, -.input-group > .form-select:not(:focus).is-invalid, .was-validated +.input-group > .form-select:not(:focus).is-invalid .was-validated, .input-group > .form-floating:not(:focus-within):invalid, .input-group > .form-floating:not(:focus-within).is-invalid { z-index: 4; diff --git a/BTCPayApp.UI/wwwroot/css/bootstrap-adaptations.css b/BTCPayApp.UI/wwwroot/css/bootstrap-adaptations.css new file mode 100644 index 00000000..45f8afe4 --- /dev/null +++ b/BTCPayApp.UI/wwwroot/css/bootstrap-adaptations.css @@ -0,0 +1,191 @@ +/* +.validation-message { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: var(--btcpay-font-size-base); + color: var(--btcpay-form-valid-color); +} + +.valid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: .1rem; + font-size: 0.75rem; + color: var(--btcpay-white); + background-color: var(--btcpay-success); + border-radius: var(--btcpay-border-radius); +} + +.modified :valid ~ .validation-message, +.modified :valid ~ .valid-tooltip, +.valid ~ .validation-message, +.valid ~ .valid-tooltip { + display: block; +} + +.modified .form-control:valid, .form-control.valid { + border-color: var(--btcpay-form-valid-border-color); + padding-right: calc(1.6em + 1rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.4em + 0.25rem) center; + background-size: calc(0.8em + 0.5rem) calc(0.8em + 0.5rem); +} + +.modified .form-control:valid:focus, .form-control.valid:focus { + border-color: var(--btcpay-form-valid-border-color); + box-shadow: 0 0 0 2px rgba(var(--btcpay-success-rgb), 0.25); +} + +.modified textarea.form-control:valid, textarea.form-control.valid { + padding-right: calc(1.6em + 1rem); + background-position: top calc(0.4em + 0.25rem) right calc(0.4em + 0.25rem); +} + +.modified .form-select:valid, .form-select.valid { + border-color: var(--btcpay-form-valid-border-color); +} + +.modified .form-select:valid:not([multiple]):not([size]), .modified .form-select:valid:not([multiple])[size="1"], .form-select.valid:not([multiple]):not([size]), .form-select.valid:not([multiple])[size="1"] { + --btcpay-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + padding-right: 5.5rem; + background-position: right 1rem center, center right 3rem; + background-size: 16px 12px, calc(0.8em + 0.5rem) calc(0.8em + 0.5rem); +} + +.modified .form-select:valid:focus, .form-select.valid:focus { + border-color: var(--btcpay-form-valid-border-color); + box-shadow: 0 0 0 2px rgba(var(--btcpay-success-rgb), 0.25); +} + +.modified .form-control-color:valid, .form-control-color.valid { + width: calc(3rem + calc(1.6em + 1rem)); +} + +.modified .form-check-input:valid, .form-check-input.valid { + border-color: var(--btcpay-form-valid-border-color); +} + +.modified .form-check-input:valid:checked, .form-check-input.valid:checked { + background-color: var(--btcpay-form-valid-color); +} + +.modified .form-check-input:valid:focus, .form-check-input.valid:focus { + box-shadow: 0 0 0 2px rgba(var(--btcpay-success-rgb), 0.25); +} + +.modified .form-check-input:valid ~ .form-check-label, .form-check-input.valid ~ .form-check-label { + color: var(--btcpay-form-valid-color); +} + +.form-check-inline .form-check-input ~ .validation-message { + margin-left: .5em; +} + +.modified .input-group > .form-control:not(:focus):valid, .input-group > .form-control:not(:focus).valid .modified, +.input-group > .form-select:not(:focus):valid, +.input-group > .form-select:not(:focus).valid .modified, +.input-group > .form-floating:not(:focus-within):valid, +.input-group > .form-floating:not(:focus-within).valid { + z-index: 3; +} +*/ +.validation-message { + width: 100%; + margin-top: 0.25rem; + font-size: var(--btcpay-font-size-base); + color: var(--btcpay-form-invalid-color); +} + +.invalid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: .1rem; + font-size: 0.75rem; + color: var(--btcpay-white); + background-color: var(--btcpay-danger); + border-radius: var(--btcpay-border-radius); +} + +.modified :invalid ~ .validation-message, +.modified :invalid ~ .invalid-tooltip, +.invalid ~ .validation-message, +.invalid ~ .invalid-tooltip { + display: block; +} + +.modified .form-control:invalid, .form-control.invalid { + border-color: var(--btcpay-form-invalid-border-color); + padding-right: calc(1.6em + 1rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.4em + 0.25rem) center; + background-size: calc(0.8em + 0.5rem) calc(0.8em + 0.5rem); +} + +.modified .form-control:invalid:focus, .form-control.invalid:focus { + border-color: var(--btcpay-form-invalid-border-color); + box-shadow: 0 0 0 2px rgba(var(--btcpay-danger-rgb), 0.25); +} + +.modified textarea.form-control:invalid, textarea.form-control.invalid { + padding-right: calc(1.6em + 1rem); + background-position: top calc(0.4em + 0.25rem) right calc(0.4em + 0.25rem); +} + +.modified .form-select:invalid, .form-select.invalid { + border-color: var(--btcpay-form-invalid-border-color); +} + +.modified .form-select:invalid:not([multiple]):not([size]), .modified .form-select:invalid:not([multiple])[size="1"], .form-select.invalid:not([multiple]):not([size]), .form-select.invalid:not([multiple])[size="1"] { + --btcpay-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); + padding-right: 5.5rem; + background-position: right 1rem center, center right 3rem; + background-size: 16px 12px, calc(0.8em + 0.5rem) calc(0.8em + 0.5rem); +} + +.modified .form-select:invalid:focus, .form-select.invalid:focus { + border-color: var(--btcpay-form-invalid-border-color); + box-shadow: 0 0 0 2px rgba(var(--btcpay-danger-rgb), 0.25); +} + +.modified .form-control-color:invalid, .form-control-color.invalid { + width: calc(3rem + calc(1.6em + 1rem)); +} + +.modified .form-check-input:invalid, .form-check-input.invalid { + border-color: var(--btcpay-form-invalid-border-color); +} + +.modified .form-check-input:invalid:checked, .form-check-input.invalid:checked { + background-color: var(--btcpay-form-invalid-color); +} + +.modified .form-check-input:invalid:focus, .form-check-input.invalid:focus { + box-shadow: 0 0 0 2px rgba(var(--btcpay-danger-rgb), 0.25); +} + +.modified .form-check-input:invalid ~ .form-check-label, .form-check-input.invalid ~ .form-check-label { + color: var(--btcpay-form-invalid-color); +} + +.form-check-inline .form-check-input ~ .validation-message { + margin-left: .5em; +} + +.modified .input-group > .form-control:not(:focus):invalid, .input-group > .form-control:not(:focus).invalid .modified, +.input-group > .form-select:not(:focus):invalid, +.input-group > .form-select:not(:focus).invalid .modified, +.input-group > .form-floating:not(:focus-within):invalid, +.input-group > .form-floating:not(:focus-within).invalid { + z-index: 4; +} diff --git a/BTCPayApp.UI/wwwroot/css/global.css b/BTCPayApp.UI/wwwroot/css/global.css index b9693b41..a0256906 100644 --- a/BTCPayApp.UI/wwwroot/css/global.css +++ b/BTCPayApp.UI/wwwroot/css/global.css @@ -3,6 +3,10 @@ border-style: none; } +:focus { + outline: none; +} + [aria-expanded] > svg.icon-caret-down { flex-shrink: 0; width: 24px; diff --git a/BTCPayApp.UI/wwwroot/css/theme.css b/BTCPayApp.UI/wwwroot/css/theme.css index 1637bb70..0e4e4d35 100644 --- a/BTCPayApp.UI/wwwroot/css/theme.css +++ b/BTCPayApp.UI/wwwroot/css/theme.css @@ -179,13 +179,13 @@ --btcpay-bg-tile: var(--btcpay-white); --btcpay-bg-dark: var(--btcpay-brand-dark); - --btcpay-body-bg: var(--btcpay-neutral-100); + --btcpay-body-bg-rgb: 248, 249, 250; + --btcpay-body-bg: rgb(var(--btcpay-body-bg-rgb)); --btcpay-body-bg-light: var(--btcpay-white); --btcpay-body-bg-medium: var(--btcpay-neutral-200); --btcpay-body-bg-striped: var(--btcpay-neutral-200); --btcpay-body-bg-hover: var(--btcpay-white); --btcpay-body-bg-active: var(--btcpay-primary); - --btcpay-body-bg-rgb: 248, 249, 250; --btcpay-body-border-light: var(--btcpay-neutral-200); --btcpay-body-border-medium: var(--btcpay-neutral-300); --btcpay-body-text: var(--btcpay-neutral-900); @@ -451,9 +451,9 @@ --btcpay-neutral-900: var(--btcpay-neutral-dark-100); --btcpay-bg-dark: var(--btcpay-neutral-50); --btcpay-bg-tile: var(--btcpay-bg-dark); + --btcpay-body-bg-rgb: 13, 17, 23; --btcpay-body-bg-light: var(--btcpay-neutral-50); --btcpay-body-bg-hover: var(--btcpay-neutral-50); - --btcpay-body-bg-rgb: 22, 27, 34; --btcpay-body-text: var(--btcpay-white); --btcpay-body-text-muted: var(--btcpay-neutral-600); --btcpay-body-text-rgb: 255, 255, 255; @@ -500,9 +500,9 @@ --btcpay-neutral-900: var(--btcpay-neutral-dark-100); --btcpay-bg-dark: var(--btcpay-neutral-50); --btcpay-bg-tile: var(--btcpay-bg-dark); + --btcpay-body-bg-rgb: 13, 17, 23; --btcpay-body-bg-light: var(--btcpay-neutral-50); --btcpay-body-bg-hover: var(--btcpay-neutral-50); - --btcpay-body-bg-rgb: 22, 27, 34; --btcpay-body-text: var(--btcpay-white); --btcpay-body-text-muted: var(--btcpay-neutral-600); --btcpay-body-text-rgb: 255, 255, 255; diff --git a/BTCPayApp.UI/wwwroot/js/global.js b/BTCPayApp.UI/wwwroot/js/global.js index 0c6301c5..42889080 100644 --- a/BTCPayApp.UI/wwwroot/js/global.js +++ b/BTCPayApp.UI/wwwroot/js/global.js @@ -1,5 +1,15 @@ Interop = { getWidth(el) { return el.clientWidth; + }, + closeModal(selector) { + const $el = document.querySelector(selector); + bootstrap.Modal.getInstance($el).hide(); + }, + addModalEvent(dotnetHelper, selector, eventName, methodName) { + const $el= document.querySelector(selector); + $el.addEventListener(`${eventName}.bs.modal`, async event => { + await dotnetHelper.invokeMethodAsync(methodName); + }); } } diff --git a/submodules/btcpayserver b/submodules/btcpayserver index 8f356260..b509445a 160000 --- a/submodules/btcpayserver +++ b/submodules/btcpayserver @@ -1 +1 @@ -Subproject commit 8f356260c4ed7b7678e6c9e36927c128d5987056 +Subproject commit b509445a8e8076f6c6078f9d62e63a746e20b0e1