From 494fcc0f9aca796437a5abb0982c3ffaf1a8a053 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 3 Nov 2025 21:28:42 +0100 Subject: [PATCH] (feature): edit components improvements, problem+json messages, account edit form init, redeploy certs controller, interfaces review --- .../Entities/LetsEncrypt/RegistrationCache.cs | 19 ++ src/LetsEncrypt/LetsEncrypt.csproj | 2 +- .../BackgroundServices/AutoRenewal.cs | 2 +- .../Controllers/CertsFlowController.cs | 76 +---- .../Controllers/WellKnownController.cs | 2 +- .../LetsEncryptServer.csproj | 2 +- src/LetsEncryptServer/Program.cs | 2 +- .../Services/AccoutService.cs | 2 +- .../Services/CacheService.cs | 5 +- .../Services/CertsFlowService.cs | 110 +++--- src/MaksIT.WebUI/package-lock.json | 312 +++++++++++++++++- src/MaksIT.WebUI/package.json | 1 + src/MaksIT.WebUI/src/AppMap.tsx | 16 + src/MaksIT.WebUI/src/axiosConfig.ts | 71 +++- .../components/editors/ButtonComponent.tsx | 24 +- .../components/editors/CheckBoxComponent.tsx | 27 +- .../editors/DateTimePickerComponent.tsx | 97 +++--- .../editors/DualListboxComponent.tsx | 13 +- .../src/components/editors/FieldContainer.tsx | 28 ++ .../editors/FileUploadComponent.tsx | 8 +- .../components/editors/ListBoxComponent.tsx | 13 +- .../editors/RadioGroupComponent.tsx | 9 +- .../components/editors/SecretComponent.tsx | 9 +- .../components/editors/SelectBoxComponent.tsx | 81 ++--- .../components/editors/TextBoxComponent.tsx | 11 +- .../src/components/editors/index.ts | 5 + src/MaksIT.WebUI/src/forms/EditAccount.tsx | 96 ++++++ src/MaksIT.WebUI/src/forms/Home.tsx | 247 +++++++------- .../src/forms/LetsEncryptTermsOfService.tsx | 120 +++++++ src/MaksIT.WebUI/src/forms/Register.tsx | 84 +++-- src/MaksIT.WebUI/src/forms/Utilities.tsx | 40 ++- .../src/functions/deep/deepPatternMatch.ts | 16 + src/MaksIT.WebUI/src/functions/deep/index.ts | 5 +- src/MaksIT.WebUI/src/functions/index.ts | 2 + src/MaksIT.WebUI/src/models/ProblemDetails.ts | 15 + .../pages/LetsEncryptTermsOfServicePage.tsx | 7 + 36 files changed, 1088 insertions(+), 491 deletions(-) create mode 100644 src/MaksIT.WebUI/src/components/editors/FieldContainer.tsx create mode 100644 src/MaksIT.WebUI/src/forms/EditAccount.tsx create mode 100644 src/MaksIT.WebUI/src/forms/LetsEncryptTermsOfService.tsx create mode 100644 src/MaksIT.WebUI/src/functions/deep/deepPatternMatch.ts create mode 100644 src/MaksIT.WebUI/src/models/ProblemDetails.ts create mode 100644 src/MaksIT.WebUI/src/pages/LetsEncryptTermsOfServicePage.tsx diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs index fbd6483..d1bc2fc 100644 --- a/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs +++ b/src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs @@ -118,4 +118,23 @@ public class RegistrationCache { foreach (var host in hostsToRemove) CachedCerts.Remove(host); } + + /// + /// + /// + /// + public Dictionary GetCertsPemPerHostname() { + var result = new Dictionary(); + if (CachedCerts == null) + return result; + + foreach (var kvp in CachedCerts) { + var hostname = kvp.Key; + var cert = kvp.Value; + if (!string.IsNullOrEmpty(cert.Cert) && !string.IsNullOrEmpty(cert.PrivatePem)) { + result[hostname] = $"{cert.Cert}\n{cert.PrivatePem}"; + } + } + return result; + } } diff --git a/src/LetsEncrypt/LetsEncrypt.csproj b/src/LetsEncrypt/LetsEncrypt.csproj index acffe0b..2c37c8d 100644 --- a/src/LetsEncrypt/LetsEncrypt.csproj +++ b/src/LetsEncrypt/LetsEncrypt.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs index 988db57..2c506f3 100644 --- a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs +++ b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs @@ -11,7 +11,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { private readonly IOptions _appSettings; private readonly ILogger _logger; private readonly ICacheService _cacheService; - private readonly ICertsInternalService _certsFlowService; + private readonly ICertsFlowService _certsFlowService; public AutoRenewal( IOptions appSettings, diff --git a/src/LetsEncryptServer/Controllers/CertsFlowController.cs b/src/LetsEncryptServer/Controllers/CertsFlowController.cs index d36619e..17a54e2 100644 --- a/src/LetsEncryptServer/Controllers/CertsFlowController.cs +++ b/src/LetsEncryptServer/Controllers/CertsFlowController.cs @@ -1,127 +1,71 @@ using Microsoft.AspNetCore.Mvc; - using MaksIT.LetsEncryptServer.Services; using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; namespace MaksIT.LetsEncryptServer.Controllers { - /// /// Certificates flow controller, used for granular testing purposes /// [ApiController] [Route("api/certs")] public class CertsFlowController : ControllerBase { - private readonly ICertsFlowService _certsFlowService; - public CertsFlowController( - ICertsFlowService certsFlowService - ) { + public CertsFlowController(ICertsFlowService certsFlowService) { _certsFlowService = certsFlowService; } - /// - /// Initialize certificate flow session - /// - /// sessionId [HttpPost("configure-client")] public async Task ConfigureClient([FromBody] ConfigureClientRequest requestData) { - var result = await _certsFlowService.ConfigureClientAsync(requestData); + var result = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging); return result.ToActionResult(); } - /// - /// Retrieves terms of service - /// - /// Session ID - /// Terms of service [HttpGet("{sessionId}/terms-of-service")] public IActionResult TermsOfService(Guid sessionId) { var result = _certsFlowService.GetTermsOfService(sessionId); return result.ToActionResult(); } - /// - /// When a new certificate session is created, create or retrieve cache data by accountId - /// - /// Session ID - /// Account ID - /// Request data - /// Account ID [HttpPost("{sessionId}/init/{accountId?}")] public async Task Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) { - var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData); + var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts); return result.ToActionResult(); } - /// - /// After account initialization, create a new order request - /// - /// Session ID - /// Request data - /// New order response [HttpPost("{sessionId}/order")] public async Task NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) { - var result = await _certsFlowService.NewOrderAsync(sessionId, requestData); + var result = await _certsFlowService.NewOrderAsync(sessionId, requestData.Hostnames, requestData.ChallengeType); return result.ToActionResult(); } - /// - /// Complete challenges for the new order - /// - /// Session ID - /// Challenges completion response [HttpPost("{sessionId}/complete-challenges")] public async Task CompleteChallenges(Guid sessionId) { var result = await _certsFlowService.CompleteChallengesAsync(sessionId); return result.ToActionResult(); } - /// - /// Get order status before certificate retrieval - /// - /// Session ID - /// Request data - /// Order status [HttpGet("{sessionId}/order-status")] public async Task GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) { - var result = await _certsFlowService.GetOrderAsync(sessionId, requestData); + var result = await _certsFlowService.GetOrderAsync(sessionId, requestData.Hostnames); return result.ToActionResult(); } - /// - /// Download certificates to local cache - /// - /// Session ID - /// Request data - /// Certificates download response [HttpPost("{sessionId}/certificates/download")] public async Task GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { - var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData); + var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData.Hostnames); return result.ToActionResult(); } - /// - /// Apply certificates from local cache to remote server - /// - /// Session ID - /// Request data - /// Certificates application response - [HttpPost("{sessionId}/certificates/apply")] - public async Task ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { - var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData); + [HttpPost("{accountId}/certificates/apply")] + public async Task ApplyCertificates(Guid accountId) { + var result = await _certsFlowService.ApplyCertificatesAsync(accountId); return result.ToActionResult(); } - /// - /// Revoke certificates - /// - /// - /// - /// [HttpPost("{sessionId}/certificates/revoke")] public async Task RevokeCertificates(Guid sessionId, [FromBody] RevokeCertificatesRequest requestData) { - var result = await _certsFlowService.RevokeCertificatesAsync(sessionId, requestData); + var result = await _certsFlowService.RevokeCertificatesAsync(sessionId, requestData.Hostnames); return result.ToActionResult(); } } diff --git a/src/LetsEncryptServer/Controllers/WellKnownController.cs b/src/LetsEncryptServer/Controllers/WellKnownController.cs index ab723bf..7b995e8 100644 --- a/src/LetsEncryptServer/Controllers/WellKnownController.cs +++ b/src/LetsEncryptServer/Controllers/WellKnownController.cs @@ -10,7 +10,7 @@ namespace MaksIT.LetsEncryptServer.Controllers; [Route(".well-known")] public class WellKnownController : ControllerBase { - private readonly ICertsRestChallengeService _certsFlowService; + private readonly ICertsFlowService _certsFlowService; public WellKnownController( IOptions appSettings, diff --git a/src/LetsEncryptServer/LetsEncryptServer.csproj b/src/LetsEncryptServer/LetsEncryptServer.csproj index a9bfbce..7b89f4d 100644 --- a/src/LetsEncryptServer/LetsEncryptServer.csproj +++ b/src/LetsEncryptServer/LetsEncryptServer.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/LetsEncryptServer/Program.cs b/src/LetsEncryptServer/Program.cs index f608432..ca0833a 100644 --- a/src/LetsEncryptServer/Program.cs +++ b/src/LetsEncryptServer/Program.cs @@ -46,7 +46,7 @@ builder.Services.AddMemoryCache(); builder.Services.RegisterLetsEncrypt(appSettings); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHostedService(); diff --git a/src/LetsEncryptServer/Services/AccoutService.cs b/src/LetsEncryptServer/Services/AccoutService.cs index 22b6f78..e971df3 100644 --- a/src/LetsEncryptServer/Services/AccoutService.cs +++ b/src/LetsEncryptServer/Services/AccoutService.cs @@ -27,7 +27,7 @@ public class AccountService : IAccountService { private readonly ILogger _logger; private readonly ICacheService _cacheService; - private readonly ICertsInternalService _certsFlowService; + private readonly ICertsFlowService _certsFlowService; public AccountService( ILogger logger, diff --git a/src/LetsEncryptServer/Services/CacheService.cs b/src/LetsEncryptServer/Services/CacheService.cs index 8345927..5c60340 100644 --- a/src/LetsEncryptServer/Services/CacheService.cs +++ b/src/LetsEncryptServer/Services/CacheService.cs @@ -86,13 +86,13 @@ public class CacheService : ICacheService, IDisposable { return Result.InternalServerError(null, message); } - var cache = JsonSerializer.Deserialize(json); + var cache = json.ToObject(); return Result.Ok(cache); } private async Task SaveToCacheInternalAsync(Guid accountId, RegistrationCache cache) { var cacheFilePath = GetCacheFilePath(accountId); - var json = JsonSerializer.Serialize(cache); + var json = cache.ToJson(); await File.WriteAllTextAsync(cacheFilePath, json); _logger.LogInformation($"Cache file saved for account {accountId}"); return Result.Ok(); @@ -114,7 +114,6 @@ public class CacheService : ICacheService, IDisposable { public async Task> LoadAccountFromCacheAsync(Guid accountId) { return await _lockManager.ExecuteWithLockAsync(() => LoadFromCacheInternalAsync(accountId)); - } public async Task SaveToCacheAsync(Guid accountId, RegistrationCache cache) { diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index 6757a9a..2a5329d 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -1,52 +1,31 @@ using Microsoft.Extensions.Options; -using MaksIT.LetsEncrypt.Entities; -using MaksIT.LetsEncrypt.Services; -using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; -using MaksIT.LetsEncrypt.Entities.LetsEncrypt; using MaksIT.Results; +using MaksIT.LetsEncrypt.Entities; +using MaksIT.LetsEncrypt.Entities.LetsEncrypt; +using MaksIT.LetsEncrypt.Services; namespace MaksIT.LetsEncryptServer.Services; -public interface ICertsCommonService { +public interface ICertsFlowService { Result GetTermsOfService(Guid sessionId); Task CompleteChallengesAsync(Guid sessionId); -} - -public interface ICertsInternalService : ICertsCommonService { Task> ConfigureClientAsync(bool isStaging); Task> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts); Task?>> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType); Task GetOrderAsync(Guid sessionId, string[] hostnames); Task GetCertificatesAsync(Guid sessionId, string[] hostnames); - Task?>> ApplyCertificatesAsync(Guid sessionId, string[] hostnames); + Task?>> ApplyCertificatesAsync(Guid accountId); Task RevokeCertificatesAsync(Guid sessionId, string[] hostnames); Task> FullFlow(bool isStaging, Guid? accountId, string description, string[] contacts, string challengeType, string[] hostnames); Task FullRevocationFlow(bool isStaging, Guid accountId, string description, string[] contacts, string[] hostnames); -} - -public interface ICertsRestService : ICertsCommonService { - Task> ConfigureClientAsync(ConfigureClientRequest requestData); - Task> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData); - Task?>> NewOrderAsync(Guid sessionId, NewOrderRequest requestData); - Task GetOrderAsync(Guid sessionId, GetOrderRequest requestData); - Task GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); - Task?>> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); - Task RevokeCertificatesAsync(Guid sessionId, RevokeCertificatesRequest requestData); -} - -public interface ICertsRestChallengeService { Result AcmeChallenge(string fileName); } -public interface ICertsFlowService - : ICertsInternalService, - ICertsRestService, - ICertsRestChallengeService { } - public class CertsFlowService : ICertsFlowService { private readonly Configuration _appSettings; private readonly ILogger _logger; + private readonly HttpClient _httpClient; private readonly ILetsEncryptService _letsEncryptService; private readonly ICacheService _cacheService; private readonly IAgentService _agentService; @@ -55,42 +34,55 @@ public class CertsFlowService : ICertsFlowService { public CertsFlowService( IOptions appSettings, ILogger logger, + HttpClient httpClient, ILetsEncryptService letsEncryptService, ICacheService cashService, IAgentService agentService ) { _appSettings = appSettings.Value; _logger = logger; + _httpClient = httpClient; _letsEncryptService = letsEncryptService; _cacheService = cashService; _agentService = agentService; _acmePath = _appSettings.AcmeFolder; } - #region Common methods public Result GetTermsOfService(Guid sessionId) { var result = _letsEncryptService.GetTermsOfServiceUri(sessionId); - return result; + if (!result.IsSuccess || result.Value == null) + return result; + + var termsOfServiceUrl = result.Value; + + try { + var pdfBytesTask = _httpClient.GetByteArrayAsync(termsOfServiceUrl); + pdfBytesTask.Wait(); + var pdfBytes = pdfBytesTask.Result; + var base64 = Convert.ToBase64String(pdfBytes); + return Result.Ok(base64); + } + catch (Exception ex) { + _logger.LogError(ex, "Failed to download or convert Terms of Service PDF"); + return Result.InternalServerError(null, $"Failed to download or convert Terms of Service PDF: {ex.Message}"); + } } public async Task CompleteChallengesAsync(Guid sessionId) { return await _letsEncryptService.CompleteChallenges(sessionId); } - #endregion - #region Internal methods public async Task> ConfigureClientAsync(bool isStaging) { var sessionId = Guid.NewGuid(); - var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging); if (!result.IsSuccess) return result.ToResultOfType(default); - return Result.Ok(sessionId); } public async Task> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) { RegistrationCache? cache = null; + if (accountId == null) { accountId = Guid.NewGuid(); } @@ -103,8 +95,8 @@ public class CertsFlowService : ICertsFlowService { else { cache = cacheResult.Value; } - } + var result = await _letsEncryptService.Init(sessionId, accountId.Value, description, contacts, cache); if (!result.IsSuccess) return result.ToResultOfType(default); @@ -151,20 +143,20 @@ public class CertsFlowService : ICertsFlowService { return await _letsEncryptService.GetOrder(sessionId, hostnames); } - public async Task?>> ApplyCertificatesAsync(Guid sessionId, string[] hostnames) { - var cacheResult = _letsEncryptService.GetRegistrationCache(sessionId); + public async Task?>> ApplyCertificatesAsync(Guid accountId) { + var cacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId); if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null) return cacheResult.ToResultOfType?>(_ => null); - var results = new Dictionary(); - foreach (var hostname in hostnames) { - CertificateCache? cert; + var cache = cacheResult.Value; + var results = cache.GetCertsPemPerHostname(); - if (cacheResult.Value.TryGetCachedCertificate(hostname, out cert)) { - var content = $"{cert.Cert}\n{cert.PrivatePem}"; - results.Add(hostname, content); - } - } + + if (cache.IsDisabled) + return Result?>.BadRequest(null, $"Account {accountId} is disabled"); + + if (cache.IsStaging) + return Result?>.UnprocessableEntity(null, $"Found certs for {string.Join(',', results.Keys)} (staging environment)"); var uploadResult = await _agentService.UploadCerts(results); if (!uploadResult.IsSuccess) @@ -224,9 +216,8 @@ public class CertsFlowService : ICertsFlowService { if (!certsResult.IsSuccess) return certsResult.ToResultOfType(default); - // Bypass applying certificates in staging mode if (!isStaging) { - var applyCertsResult = await ApplyCertificatesAsync(sessionId, hostnames); + var applyCertsResult = await ApplyCertificatesAsync(accountId ?? Guid.Empty); if (!applyCertsResult.IsSuccess) return applyCertsResult.ToResultOfType(_ => null); } @@ -251,32 +242,7 @@ public class CertsFlowService : ICertsFlowService { return Result.Ok(); } - #endregion - #region REST methods - public async Task> ConfigureClientAsync(ConfigureClientRequest requestData) { - return await ConfigureClientAsync(requestData.IsStaging); - } - public async Task> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData) { - return await InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts); - } - public async Task>> NewOrderAsync(Guid sessionId, NewOrderRequest requestData) { - return await NewOrderAsync(sessionId, requestData.Hostnames, requestData.ChallengeType); - } - public async Task GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) { - return await GetCertificatesAsync(sessionId, requestData.Hostnames); - } - public async Task GetOrderAsync(Guid sessionId, GetOrderRequest requestData) { - return await GetOrderAsync(sessionId, requestData.Hostnames); - } - - public async Task>> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) => - await ApplyCertificatesAsync(sessionId, requestData.Hostnames); - public async Task RevokeCertificatesAsync(Guid sessionId, RevokeCertificatesRequest requestData) => - await RevokeCertificatesAsync(sessionId, requestData.Hostnames); - #endregion - - #region Acme Challenge REST methods public Result AcmeChallenge(string fileName) { DeleteExporedChallenges(); @@ -295,6 +261,7 @@ public class CertsFlowService : ICertsFlowService { var creationTime = File.GetCreationTime(file); var timeDifference = currentDate - creationTime; + if (timeDifference.TotalDays > 1) { File.Delete(file); _logger.LogInformation($"Deleted file: {file}"); @@ -305,5 +272,4 @@ public class CertsFlowService : ICertsFlowService { } } } - #endregion } diff --git a/src/MaksIT.WebUI/package-lock.json b/src/MaksIT.WebUI/package-lock.json index 6fde61f..fcfc798 100644 --- a/src/MaksIT.WebUI/package-lock.json +++ b/src/MaksIT.WebUI/package-lock.json @@ -18,6 +18,7 @@ "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-pdf": "^10.2.0", "react-redux": "^9.2.0", "react-router-dom": "^7.7.1", "react-virtualized": "^9.22.6", @@ -769,6 +770,191 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.81.tgz", + "integrity": "sha512-ReCjd5SYI/UKx/olaQLC4GtN6wUQGjlgHXs1lvUvWGXfBMR3Fxnik3cL+OxKN5ithNdoU0/GlCrdKcQDFh2XKQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.81", + "@napi-rs/canvas-darwin-arm64": "0.1.81", + "@napi-rs/canvas-darwin-x64": "0.1.81", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.81", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.81", + "@napi-rs/canvas-linux-arm64-musl": "0.1.81", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.81", + "@napi-rs/canvas-linux-x64-gnu": "0.1.81", + "@napi-rs/canvas-linux-x64-musl": "0.1.81", + "@napi-rs/canvas-win32-x64-msvc": "0.1.81" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.81.tgz", + "integrity": "sha512-78Lz+AUi+MsWupyZjXwpwQrp1QCwncPvRZrdvrROcZ9Gq9grP7LfQZiGdR8LKyHIq3OR18mDP+JESGT15V1nXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.81.tgz", + "integrity": "sha512-omejuKgHWKDGoh8rsgsyhm/whwxMaryTQjJTd9zD7hiB9/rzcEEJLHnzXWR5ysy4/tTjHaQotE6k2t8eodTLnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.81.tgz", + "integrity": "sha512-EYfk+co6BElq5DXNH9PBLYDYwc4QsvIVbyrsVHsxVpn4p6Y3/s8MChgC69AGqj3vzZBQ1qx2CRCMtg5cub+XuQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.81.tgz", + "integrity": "sha512-teh6Q74CyAcH31yLNQGR9MtXSFxlZa5CI6vvNUISI14gWIJWrhOwUAOly+KRe1aztWR0FWTVSPxM4p5y+06aow==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.81.tgz", + "integrity": "sha512-AGEopHFYRzJOjxY+2G1RmHPRnuWvO3Qdhq7sIazlSjxb3Z6dZHg7OB/4ZimXaimPjDACm9qWa6t5bn9bhXvkcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.81.tgz", + "integrity": "sha512-Bj3m1cl4GIhsigkdwOxii4g4Ump3/QhNpx85IgAlCCYXpaly6mcsWpuDYEabfIGWOWhDUNBOndaQUPfWK1czOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.81.tgz", + "integrity": "sha512-yg/5NkHykVdwPlD3XObwCa/EswkOwLHswJcI9rHrac+znHsmCSj5AMX/RTU9Z9F6lZTwL60JM2Esit33XhAMiw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.81.tgz", + "integrity": "sha512-tPfMpSEBuV5dJSKexO/UZxpOqnYTaNbG8aKa1ek8QsWu+4SJ/foWkaxscra/RUv85vepx6WWDjzBNbNJsTnO0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.81.tgz", + "integrity": "sha512-1L0xnYgzqn8Baef+inPvY4dKqdmw3KCBoe0NEDgezuBZN7MA5xElwifoG8609uNdrMtJ9J6QZarsslLRVqri7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.81.tgz", + "integrity": "sha512-57ryVbhm/z7RE9/UVcS7mrLPdlayLesy+9U0Uf6epCoeSGrs99tfieCcgZWFbIgmByQ1AZnNtFI2N6huqDLlWQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1761,7 +1947,6 @@ "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1849,7 +2034,6 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -2095,7 +2279,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2674,6 +2857,15 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2965,7 +3157,6 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4502,6 +4693,24 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4511,6 +4720,23 @@ "node": ">= 0.4" } }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4866,6 +5092,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5011,7 +5249,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5021,7 +5258,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5041,12 +5277,49 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, + "node_modules/react-pdf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.2.0.tgz", + "integrity": "sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-pdf/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5125,8 +5398,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5667,8 +5939,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.2", @@ -5698,6 +5969,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -5736,7 +6013,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5867,7 +6143,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5964,7 +6239,6 @@ "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -6055,7 +6329,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6063,6 +6336,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/src/MaksIT.WebUI/package.json b/src/MaksIT.WebUI/package.json index d9b8b15..3fbb41b 100644 --- a/src/MaksIT.WebUI/package.json +++ b/src/MaksIT.WebUI/package.json @@ -20,6 +20,7 @@ "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-pdf": "^10.2.0", "react-redux": "^9.2.0", "react-router-dom": "^7.7.1", "react-virtualized": "^9.22.6", diff --git a/src/MaksIT.WebUI/src/AppMap.tsx b/src/MaksIT.WebUI/src/AppMap.tsx index 55d747f..50866f5 100644 --- a/src/MaksIT.WebUI/src/AppMap.tsx +++ b/src/MaksIT.WebUI/src/AppMap.tsx @@ -9,6 +9,7 @@ import { UserButton } from './components/UserButton' import { Toast } from './components/Toast' import { UtilitiesPage } from './pages/UtilitiesPage' import { RegisterPage } from './pages/RegisterPage' +import { LetsEncryptTermsOfServicePage } from './pages/LetsEncryptTermsOfServicePage' interface LayoutWrapperProps { @@ -83,6 +84,12 @@ const AppMap: AppMapType[] = [ routes: ['/utilities'], page: UtilitiesPage, linkArea: [LinkArea.SideMenu] + }, + { + title: 'Terms of Service', + routes: ['/terms-of-service'], + page: LetsEncryptTermsOfServicePage, + linkArea: [LinkArea.SideMenu] } // { @@ -153,6 +160,15 @@ enum ApiRoutes { // ACCOUNT_ID_HOSTNAMES = 'GET|/account/{accountId}/hostnames', // ACCOUNT_ID_HOSTNAME_ID = 'GET|/account/{accountId}/hostname/{index}', + // Agents + AGENT_TEST = 'GET|/agent/test', + + // Certs flow + CERTS_FLOW_CONFIGURE_CLIENT = 'POST|/certs/configure-client', + CERTS_FLOW_TERMS_OF_SERVICE = 'GET|/certs/{sessionId}/terms-of-service', + CERTS_FLOW_CERTIFICATES_APPLY = 'POST|/certs/{accountId}/certificates/apply', + + // Secrets generateSecret = 'GET|/secret/generatesecret', diff --git a/src/MaksIT.WebUI/src/axiosConfig.ts b/src/MaksIT.WebUI/src/axiosConfig.ts index ecf4fe1..12fc327 100644 --- a/src/MaksIT.WebUI/src/axiosConfig.ts +++ b/src/MaksIT.WebUI/src/axiosConfig.ts @@ -4,6 +4,11 @@ import { ApiRoutes, GetApiRoute } from './AppMap' import { store } from './redux/store' import { refreshJwt } from './redux/slices/identitySlice' import { hideLoader, showLoader } from './redux/slices/loaderSlice' +import { addToast } from './components/Toast/addToast' +import { de } from 'zod/v4/locales' +import { deepPatternMatch } from './functions' +import { ProblemDetails, ProblemDetailsProto } from './models/ProblemDetails' +import { add } from 'lodash' // Create an Axios instance const axiosInstance = axios.create({ @@ -73,19 +78,35 @@ axiosInstance.interceptors.response.use( error => { // Handle response error store.dispatch(hideLoader()) - if (error.response && error.response.status === 401) { + if (error.response) { + if (error.response.status === 401) { // Handle unauthorized error (e.g., redirect to login) + } + else { + const contentType = error.response.headers['content-type'] + + if (contentType && contentType.includes('application/problem+json')) { + const problem = error.response.data as ProblemDetails + addToast(`${problem.title}: ${problem.detail}`, 'error') + } + } } return Promise.reject(error) } ) -const getData = async (url: string): Promise => { +/** + * Performs a GET request and returns the response data. + * @param url The endpoint URL. + * @param timeout Optional timeout in milliseconds to override the default. + */ +const getData = async (url: string, timeout?: number): Promise => { try { const response = await axiosInstance.get(url, { headers: { 'Content-Type': 'application/json' - } + }, + ...(timeout ? { timeout } : {}) }) return response.data } catch { @@ -94,13 +115,21 @@ const getData = async (url: string): Promise = } } -const postData = async (url: string, data: TRequest): Promise => { +/** + * Performs a POST request with the given data and returns the response data. + * @param url The endpoint URL. + * @param data The request payload. + * @param timeout Optional timeout in milliseconds to override the default. + */ +const postData = async (url: string, data?: TRequest, timeout?: number): Promise => { try { const response = await axiosInstance.post(url, data, { headers: { 'Content-Type': 'application/json' - } + }, + ...(timeout ? { timeout } : {}) }) + return response.data } catch { // Error is already handled by interceptors, so just return undefined @@ -108,12 +137,19 @@ const postData = async (url: string, data: TRequest): Promi } } -const patchData = async (url: string, data: TRequest): Promise => { +/** + * Performs a PATCH request with the given data and returns the response data. + * @param url The endpoint URL. + * @param data The request payload. + * @param timeout Optional timeout in milliseconds to override the default. + */ +const patchData = async (url: string, data: TRequest, timeout?: number): Promise => { try { const response = await axiosInstance.patch(url, data, { headers: { 'Content-Type': 'application/json' - } + }, + ...(timeout ? { timeout } : {}) }) return response.data } catch { @@ -122,12 +158,19 @@ const patchData = async (url: string, data: TRequest): Prom } } -const putData = async (url: string, data: TRequest): Promise => { +/** + * Performs a PUT request with the given data and returns the response data. + * @param url The endpoint URL. + * @param data The request payload. + * @param timeout Optional timeout in milliseconds to override the default. + */ +const putData = async (url: string, data: TRequest, timeout?: number): Promise => { try { const response = await axiosInstance.put(url, data, { headers: { 'Content-Type': 'application/json' - } + }, + ...(timeout ? { timeout } : {}) }) return response.data } catch { @@ -136,12 +179,18 @@ const putData = async (url: string, data: TRequest): Promis } } -const deleteData = async (url: string): Promise => { +/** + * Performs a DELETE request and returns the response data. + * @param url The endpoint URL. + * @param timeout Optional timeout in milliseconds to override the default. + */ +const deleteData = async (url: string, timeout?: number): Promise => { try { const response = await axiosInstance.delete(url, { headers: { 'Content-Type': 'application/json' - } + }, + ...(timeout ? { timeout } : {}) }) return response.data } catch { diff --git a/src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx b/src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx index 8bed45c..067d2c1 100644 --- a/src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx @@ -1,8 +1,7 @@ -import React from 'react' +import { ReactNode } from 'react' import { Link } from 'react-router-dom' -interface ConditionalButtonProps { - label: string; +interface CommonButtonProps { colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; route?: string; buttonHierarchy?: 'primary' | 'secondary' | 'success' | 'error' | 'warning'; @@ -10,10 +9,12 @@ interface ConditionalButtonProps { disabled?: boolean; } -const ButtonComponent: React.FC = (props) => { +type ButtonComponentProps = + | ({ label: string; children?: never } & CommonButtonProps) + | ({ children: ReactNode; label?: never } & CommonButtonProps); +const ButtonComponent: React.FC = (props) => { const { - label, colspan, route, buttonHierarchy, @@ -21,6 +22,9 @@ const ButtonComponent: React.FC = (props) => { disabled = false } = props + const isChildren = 'children' in props && props.children !== undefined + const content = 'label' in props ? props.label : props.children + const handleClick = (e?: React.MouseEvent) => { if (disabled) { e?.preventDefault() @@ -53,25 +57,27 @@ const ButtonComponent: React.FC = (props) => { const disabledClass = disabled ? 'opacity-50 cursor-default' : 'cursor-pointer' + const centeringClass = isChildren ? 'flex justify-center items-center' : 'text-center' + return route ? ( - {label} + {content} ) : ( ) } diff --git a/src/MaksIT.WebUI/src/components/editors/CheckBoxComponent.tsx b/src/MaksIT.WebUI/src/components/editors/CheckBoxComponent.tsx index 42bd8e7..c97fc2c 100644 --- a/src/MaksIT.WebUI/src/components/editors/CheckBoxComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/CheckBoxComponent.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { FieldContainer } from './FieldContainer' interface CheckBoxComponentProps { colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; @@ -36,23 +37,15 @@ const CheckBoxComponent: React.FC = (props) => { } return ( -
- - {errorText && ( -

- {errorText} -

- )} -
+ + + ) } diff --git a/src/MaksIT.WebUI/src/components/editors/DateTimePickerComponent.tsx b/src/MaksIT.WebUI/src/components/editors/DateTimePickerComponent.tsx index 0c064ef..d7008d8 100644 --- a/src/MaksIT.WebUI/src/components/editors/DateTimePickerComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/DateTimePickerComponent.tsx @@ -3,6 +3,7 @@ import { parseISO, formatISO, format, getDaysInMonth, addMonths, subMonths } fro import { ButtonComponent } from './ButtonComponent' import { TextBoxComponent } from './TextBoxComponent' import { CircleX } from 'lucide-react' +import { FieldContainer } from './FieldContainer' const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm' @@ -119,9 +120,8 @@ const DateTimePickerComponent: FC = ({ }, [showDropdown]) return ( -
- -
+ +
= ({
{actionButtons()}
-
- {showDropdown && !readOnly && !disabled && ( -
-
- - {format(currentViewDate, 'MMMM yyyy')} - + {showDropdown && !readOnly && !disabled && ( +
+
+ + {format(currentViewDate, 'MMMM yyyy')} + +
+
+ {daysArray.map((day) => ( +
handleDayClick(day)} + className={`p-2 cursor-pointer text-center ${ + tempDate.getDate() === day && + tempDate.getMonth() === currentViewDate.getMonth() && + tempDate.getFullYear() === currentViewDate.getFullYear() + ? 'bg-blue-500 text-white rounded' + : 'hover:bg-gray-200 rounded' + }`} + > + {day} +
+ ))} +
+
+ +
+
+ + +
-
- {daysArray.map((day) => ( -
handleDayClick(day)} - className={`p-2 cursor-pointer text-center ${ - tempDate.getDate() === day && - tempDate.getMonth() === currentViewDate.getMonth() && - tempDate.getFullYear() === currentViewDate.getFullYear() - ? 'bg-blue-500 text-white rounded' - : 'hover:bg-gray-200 rounded' - }`} - > - {day} -
- ))} -
-
- -
-
- - -
-
- )} - {errorText &&

{errorText}

} -
+ )} +
+ ) } diff --git a/src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx b/src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx index 3fde07e..93963c3 100644 --- a/src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react' +import { FieldContainer } from './FieldContainer' interface DualListboxComponentProps { label?: string; @@ -44,10 +45,7 @@ const DualListboxComponent: React.FC = (props) => { } return ( -
- +

{availableItemsLabel}

@@ -92,12 +90,7 @@ const DualListboxComponent: React.FC = (props) => {
- {errorText && ( -

- {errorText} -

- )} -
+ ) } diff --git a/src/MaksIT.WebUI/src/components/editors/FieldContainer.tsx b/src/MaksIT.WebUI/src/components/editors/FieldContainer.tsx new file mode 100644 index 0000000..0459be1 --- /dev/null +++ b/src/MaksIT.WebUI/src/components/editors/FieldContainer.tsx @@ -0,0 +1,28 @@ +import { FC, ReactNode } from 'react' + +interface FieldContainerProps { + colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + label?: string; + errorText?: string; + children: ReactNode +} + +const FieldContainer: FC = (props) => { + const { + colspan, + label, + errorText, + children + } = props + + return
+ + {children} +

{errorText || '\u00A0'}

+
+} + + +export { + FieldContainer +} \ No newline at end of file diff --git a/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx b/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx index 708de0f..d3dd35f 100644 --- a/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx @@ -1,5 +1,6 @@ import React, { useRef, useState } from 'react' import { ButtonComponent } from './ButtonComponent' +import { TrashIcon } from 'lucide-react' interface FileUploadComponentProps { label?: string @@ -124,17 +125,18 @@ const FileUploadComponent: React.FC = ({ {/* Clear selection button */} + > + + {/* Select files button */} = (props) => { } return ( -
- +

{itemsLabel}

    @@ -53,12 +51,7 @@ const ListboxComponent: React.FC = (props) => { ))}
- {errorText && ( -

- {errorText} -

- )} -
+ ) } diff --git a/src/MaksIT.WebUI/src/components/editors/RadioGroupComponent.tsx b/src/MaksIT.WebUI/src/components/editors/RadioGroupComponent.tsx index 64f4adc..5ac5d37 100644 --- a/src/MaksIT.WebUI/src/components/editors/RadioGroupComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/RadioGroupComponent.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' +import { FieldContainer } from './FieldContainer' interface RadioOption { value: string @@ -45,8 +46,7 @@ const RadioGroupComponent: React.FC = (props) => { } return ( -
- {label && } +
{options.map(option => { // Use default cursor (arrow) if disabled or readOnly, else pointer @@ -70,10 +70,7 @@ const RadioGroupComponent: React.FC = (props) => { ) })}
- {errorText && ( -

{errorText}

- )} -
+ ) } diff --git a/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx b/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx index 504be7a..60c342a 100644 --- a/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx @@ -3,6 +3,7 @@ import { ChangeEvent, FC, useRef, useState } from 'react' import { TrngResponse } from '../../models/TrngResponse' import { getData } from '../../axiosConfig' import { ApiRoutes, GetApiRoute } from '../../AppMap' +import { FieldContainer } from './FieldContainer' interface PasswordGeneratorProps { @@ -114,9 +115,7 @@ const SecretComponent: FC = (props) => { return ( -
- - +
= (props) => { {actionButtons()}
- - {errorText &&

{errorText}

} -
+ ) } diff --git a/src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx b/src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx index 92056ad..e8423e5 100644 --- a/src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx @@ -1,6 +1,7 @@ import { debounce } from 'lodash' import { CircleX } from 'lucide-react' import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FieldContainer } from './FieldContainer' export interface SelectBoxComponentOption { value: string | number @@ -167,53 +168,53 @@ const SelectBoxComponent: FC = (props) => { } return ( -
- {/* Label for the select input */} - +
- + { if (!disabled) setShowDropdown(true) }} - // Delay closing dropdown to allow click events on options. - onBlur={() => setTimeout(() => setShowDropdown(false), 200)} - /> + disabled={readOnly || disabled} + // Open dropdown when input is focused. + onFocus={() => { if (!disabled) setShowDropdown(true) }} + // Delay closing dropdown to allow click events on options. + onBlur={() => setTimeout(() => setShowDropdown(false), 200)} + /> - {/* Action Buttons */} -
- {actionButtons()} + {/* Action Buttons */} +
+ {actionButtons()} +
+ + {showDropdown && !disabled && ( +
+ {options.length > 0 ? ( + options.map((option) => ( +
handleOptionClick(option.value)} + > + {option.label} +
+ )) + ) : ( +
No options found
+ )} +
+ )}
- - {showDropdown && !disabled && ( -
- {options.length > 0 ? ( - options.map((option) => ( -
handleOptionClick(option.value)} - > - {option.label} -
- )) - ) : ( -
No options found
- )} -
- )} - {errorText &&

{errorText}

} -
+ ) } diff --git a/src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx b/src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx index ab80d62..a30b9d3 100644 --- a/src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx @@ -1,5 +1,6 @@ import { Eye, EyeOff } from 'lucide-react' import { ChangeEvent, FC, useEffect, useRef, useState } from 'react' +import { FieldContainer } from './FieldContainer' interface TextBoxComponentProps { label: string @@ -48,7 +49,7 @@ const TextBoxComponent: FC = (props) => { // Se il type è "textarea", comportamento invariato if (type === 'textarea') { return ( -
+