(feature): simple login init

This commit is contained in:
Maksym Sadovnychyy 2025-11-08 23:11:03 +01:00
parent 1e2d4156a5
commit c6dbf6d195
32 changed files with 661 additions and 126 deletions

View File

@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.2" /> <PackageReference Include="MaksIT.Core" Version="1.5.3" />
<PackageReference Include="MaksIT.Results" Version="1.1.1" /> <PackageReference Include="MaksIT.Results" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />

View File

@ -0,0 +1,64 @@
using Microsoft.Extensions.Options;
using MaksIT.LetsEncryptServer.Domain;
using MaksIT.LetsEncryptServer.Services;
namespace MaksIT.LetsEncryptServer.BackgroundServices {
public class Initialization : BackgroundService {
private readonly IServiceProvider _serviceProvider;
private readonly Configuration _appSettings;
private readonly ISettingsService _settingsService;
public Initialization(
IOptions<Configuration> appSettings,
IServiceProvider serviceProvider,
ISettingsService settingsService
) {
_appSettings = appSettings.Value;
_serviceProvider = serviceProvider;
_settingsService = settingsService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
// using var scope = _serviceProvider.CreateScope();
// TODO: Add your user initialization logic here.
// Example:
// var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
// await userService.InitializeUsersAsync();
var dataDir = Path.Combine(Path.DirectorySeparatorChar.ToString(), "data");
var settingsPath = Path.Combine(dataDir, "settings.json");
// Ensure the data directory exists
if (!Directory.Exists(dataDir)) {
Directory.CreateDirectory(dataDir);
}
var loadSettingsResult = await _settingsService.LoadAsync();
if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) {
throw new Exception("Failed to load settings.");
}
var settings = loadSettingsResult.Value;
if (!settings.Init) {
var initializeResult = settings.Initialize(_appSettings.Auth.Pepper);
if (!initializeResult.IsSuccess || initializeResult.Value == null)
throw new Exception(string.Join(", ", initializeResult.Messages));
settings = initializeResult.Value;
var saveResult = await _settingsService.SaveAsync(settings);
if (!saveResult.IsSuccess) {
throw new Exception("Failed to save initialized settings.");
}
}
await Task.CompletedTask;
}
}
}

View File

@ -9,7 +9,26 @@ namespace MaksIT.LetsEncryptServer {
public required string ServiceToReload { get; set; } public required string ServiceToReload { get; set; }
} }
public class Auth {
public required string Secret { get; set; }
public required string Issuer { get; set; }
public required string Audience { get; set; }
public required int Expiration { get; set; }
public required int RefreshExpiration { get; set; }
public required string Pepper { get; set; }
}
public class Configuration : ILetsEncryptConfiguration { public class Configuration : ILetsEncryptConfiguration {
public required Auth Auth { get; set; }
public required string SettingsFile { get; set; }
public required string Production { get; set; } public required string Production { get; set; }
public required string Staging { get; set; } public required string Staging { get; set; }

View File

@ -8,7 +8,7 @@ namespace MaksIT.LetsEncryptServer.Controllers;
[ApiController] [ApiController]
[Route("api")] [Route("api")]
public class AccountController : ControllerBase { public class AccountController : ControllerBase {
private readonly IAccountRestService _accountService; private readonly IAccountService _accountService;
public AccountController( public AccountController(
IAccountService accountService IAccountService accountService

View File

@ -0,0 +1,49 @@
using MaksIT.LetsEncryptServer.Services;
using Microsoft.AspNetCore.Mvc;
using Models.LetsEncryptServer.Identity.Login;
using Models.LetsEncryptServer.Identity.Logout;
namespace MaksIT.LetsEncryptServer.Controllers;
[ApiController]
[Route("api/identity")]
public class IdentityController(
IIdentityService identityService
) : ControllerBase {
private readonly IIdentityService _identityService = identityService;
#region Login/Refresh/Logout
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> Login([FromBody] LoginRequest requestData) {
var result = await _identityService.LoginAsync(requestData);
return result.ToActionResult();
}
//[HttpPost("refresh")]
//[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
//public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest requestData) {
// var result = await _identityService.RefreshTokenAsync(requestData);
// return result.ToActionResult();
//}
//[ServiceFilter(typeof(JwtAuthorizationFilter))]
//[HttpPost("logout")]
//[ProducesResponseType(StatusCodes.Status200OK)]
//public async Task<IActionResult> Logout([FromBody] LogoutRequest requetData) {
// var jwtTokenDataResult = HttpContext.GetJwtTokenData();
// if (!jwtTokenDataResult.IsSuccess || jwtTokenDataResult.Value == null)
// return jwtTokenDataResult.ToActionResult();
// var jwtTokenData = jwtTokenDataResult.Value;
// var result = await _identityService.Logout(jwtTokenData, requetData);
// return result.ToActionResult();
//}
#endregion
}

View File

@ -0,0 +1,59 @@
using MaksIT.Core.Abstractions.Domain;
using MaksIT.Core.Security;
using MaksIT.Results;
namespace MaksIT.LetsEncryptServer.Domain;
public class Settings : DomainObjectBase {
public bool Init { get; set; }
public List<User> Users { get; set; } = [];
public Settings() {}
public Result<Settings?> Initialize(string pepper) {
var userResult = new User("admin")
.SetPassword("password", pepper);
if (!userResult.IsSuccess || userResult.Value == null) {
return userResult.ToResultOfType<Settings?>(_ => null);
}
Init = true;
Users = [userResult.Value];
return Result<Settings?>.Ok(this);
}
public Result<User?> GetUserByName(string name) {
var user = Users.FirstOrDefault(x => x.Name == name);
if (user == null)
return Result<User?>.NotFound(null, "User not found.");
return Result<User?>.Ok(user);
}
public Result<Settings?> AddUser(string name, string password, string pepper) {
var setPasswordResult = new User(name)
.SetPassword(password, pepper);
if (!setPasswordResult.IsSuccess || setPasswordResult.Value == null)
return setPasswordResult.ToResultOfType<Settings?>(_ => null);
var user = setPasswordResult.Value;
Users.Add(user);
return Result<Settings?>.Ok(this);
}
public Result<Settings?> RemoveUser(string name) {
if (Users.Any(x => x.Name == name)) {
Users = [.. Users.Where(u => u.Name != name)];
return Result<Settings?>.Ok(this);
}
return Result<Settings?>.NotFound(null, "User not found.");
}
}

View File

@ -0,0 +1,64 @@
using MaksIT.Core.Abstractions.Domain;
using MaksIT.Core.Security;
using MaksIT.Results;
namespace MaksIT.LetsEncryptServer.Domain;
public class User(
Guid id,
string name
) : DomainDocumentBase<Guid>(id) {
public string Name { get; private set; } = name;
public string Salt { get; private set; } = string.Empty;
public string Hash { get; private set; } = string.Empty;
public User(
string name
) : this(
Guid.NewGuid(),
name
) { }
// Set or change password (returns this for chaining)
public Result<User?> SetPassword(string password, string pepper) {
if (!PasswordHasher.TryCreateSaltedHash(password, pepper, out var saltedHash, out var errorMessage))
return Result<User?>.InternalServerError(null, errorMessage);
Salt = saltedHash.Value.Salt;
Hash = saltedHash.Value.Hash;
return Result<User?>.Ok(this);
}
// Reset password to a new value (returns this for chaining)
public Result<User?> ResetPassword(string newPassword, string pepper) => SetPassword(newPassword, pepper);
// Change user name
public User ChangeName(string newName) {
if (string.IsNullOrWhiteSpace(newName))
throw new ArgumentException("Name cannot be empty.", nameof(newName));
Name = newName;
return this;
}
// Validate password
public Result ValidatePassword(string password, string pepper) {
if (PasswordHasher.TryValidateHash(password, Salt, Hash, pepper, out var isValid, out var errorMessage)) {
if (isValid)
return Result.Ok();
return Result.Unauthorized("Invalid password.");
}
return Result<User?>.InternalServerError(null, errorMessage);
}
// For persistence
public User SeltSaltedHash(string salt, string hash) {
Salt = salt;
Hash = hash;
return this;
}
}

View File

@ -0,0 +1,6 @@
namespace MaksIT.LetsEncryptServer.Dto;
public class SettingsDto {
public required bool Init { get; set; }
public required List<UserDto> Users { get; set; } = [];
}

View File

@ -0,0 +1,8 @@
namespace MaksIT.LetsEncryptServer.Dto;
public class UserDto {
public required string Id { get; set; }
public required string Name { get; set; }
public required string Salt { get; set; }
public required string Hash { get; set; }
}

View File

@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath> <DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
</PropertyGroup> </PropertyGroup>

View File

@ -1,10 +1,10 @@
using System.Text.Json.Serialization;
using MaksIT.Core.Logging; using MaksIT.Core.Logging;
using MaksIT.Core.Webapi.Middlewares; using MaksIT.Core.Webapi.Middlewares;
using MaksIT.LetsEncrypt.Extensions; using MaksIT.LetsEncrypt.Extensions;
using MaksIT.LetsEncryptServer; using MaksIT.LetsEncryptServer;
using MaksIT.LetsEncryptServer.BackgroundServices;
using MaksIT.LetsEncryptServer.Services; using MaksIT.LetsEncryptServer.Services;
using System.Text.Json.Serialization; using MaksIT.LetsEncryptServer.BackgroundServices;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -48,11 +48,19 @@ builder.Services.AddMemoryCache();
builder.Services.RegisterLetsEncrypt(appSettings); builder.Services.RegisterLetsEncrypt(appSettings);
#region Work with files concurrently
builder.Services.AddSingleton<ICacheService, CacheService>(); builder.Services.AddSingleton<ICacheService, CacheService>();
builder.Services.AddSingleton<ISettingsService, SettingsService>();
#endregion
builder.Services.AddHttpClient<ICertsFlowService, CertsFlowService>(); builder.Services.AddHttpClient<ICertsFlowService, CertsFlowService>();
builder.Services.AddSingleton<IAccountService, AccountService>();
builder.Services.AddHttpClient<IAgentService, AgentService>(); builder.Services.AddHttpClient<IAgentService, AgentService>();
builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<IIdentityService, IdentityService>();
// Hosted services
builder.Services.AddHostedService<AutoRenewal>(); builder.Services.AddHostedService<AutoRenewal>();
builder.Services.AddHostedService<Initialization>();
var app = builder.Build(); var app = builder.Build();

View File

@ -2,22 +2,14 @@
using LetsEncryptServer.Abstractions; using LetsEncryptServer.Abstractions;
using MaksIT.Core.Webapi.Models; using MaksIT.Core.Webapi.Models;
using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities;
using MaksIT.Models;
using MaksIT.Models.LetsEncryptServer.Account.Requests; using MaksIT.Models.LetsEncryptServer.Account.Requests;
using MaksIT.Models.LetsEncryptServer.Account.Responses; using MaksIT.Models.LetsEncryptServer.Account.Responses;
using MaksIT.Results; using MaksIT.Results;
using System;
using static System.Collections.Specialized.BitVector32;
namespace MaksIT.LetsEncryptServer.Services; namespace MaksIT.LetsEncryptServer.Services;
public interface IAccountInternalService { public interface IAccountService {
}
public interface IAccountRestService {
Task<Result<GetAccountResponse[]?>> GetAccountsAsync(); Task<Result<GetAccountResponse[]?>> GetAccountsAsync();
Task<Result<GetAccountResponse?>> GetAccountAsync(Guid accountId); Task<Result<GetAccountResponse?>> GetAccountAsync(Guid accountId);
Task<Result<GetAccountResponse?>> PostAccountAsync(PostAccountRequest requestData); Task<Result<GetAccountResponse?>> PostAccountAsync(PostAccountRequest requestData);
@ -25,8 +17,6 @@ public interface IAccountRestService {
Task<Result> DeleteAccountAsync(Guid accountId); Task<Result> DeleteAccountAsync(Guid accountId);
} }
public interface IAccountService : IAccountInternalService, IAccountRestService { }
public class AccountService : ServiceBase, IAccountService { public class AccountService : ServiceBase, IAccountService {
private readonly ILogger<CacheService> _logger; private readonly ILogger<CacheService> _logger;

View File

@ -195,9 +195,12 @@ public class CertsFlowService : ICertsFlowService {
var sessionId = sessionResult.Value.Value; var sessionId = sessionResult.Value.Value;
var initResult = await InitAsync(sessionId, accountId, description, contacts); var initResult = await InitAsync(sessionId, accountId, description, contacts);
if (!initResult.IsSuccess) if (!initResult.IsSuccess || initResult.Value == null)
return initResult.ToResultOfType<Guid?>(_ => null); return initResult.ToResultOfType<Guid?>(_ => null);
if (accountId == null)
accountId = initResult.Value;
var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType); var challengesResult = await NewOrderAsync(sessionId, hostnames, challengeType);
if (!challengesResult.IsSuccess) if (!challengesResult.IsSuccess)
return challengesResult.ToResultOfType<Guid?>(_ => null); return challengesResult.ToResultOfType<Guid?>(_ => null);
@ -217,7 +220,7 @@ public class CertsFlowService : ICertsFlowService {
return certsResult.ToResultOfType<Guid?>(default); return certsResult.ToResultOfType<Guid?>(default);
if (!isStaging) { if (!isStaging) {
var applyCertsResult = await ApplyCertificatesAsync(accountId ?? Guid.Empty); var applyCertsResult = await ApplyCertificatesAsync(accountId.Value);
if (!applyCertsResult.IsSuccess) if (!applyCertsResult.IsSuccess)
return applyCertsResult.ToResultOfType<Guid?>(_ => null); return applyCertsResult.ToResultOfType<Guid?>(_ => null);
} }

View File

@ -0,0 +1,108 @@
using MaksIT.Core.Security.JWT;
using MaksIT.LetsEncryptServer.Domain;
using MaksIT.Results;
using Microsoft.Extensions.Options;
using Models.LetsEncryptServer.Identity.Login;
using Models.LetsEncryptServer.Identity.Logout;
using System.Security.Claims;
namespace MaksIT.LetsEncryptServer.Services;
public interface IIdentityService {
#region Login/Refresh/Logout
Task<Result<LoginResponse?>> LoginAsync(LoginRequest requestData);
//Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData);
//Task<Result> Logout(JwtTokenData jwtTokenData, LogoutRequest requestData);
#endregion
}
public class IdentityService(
IOptions<Configuration> appsettings,
ISettingsService settingsService
) : IIdentityService {
private readonly Configuration _appSettings = appsettings.Value;
private readonly ISettingsService _settingsService = settingsService;
#region Login/Refresh/Logout
public async Task<Result<LoginResponse?>> LoginAsync(LoginRequest requestData) {
var loadSettingsResult = await _settingsService.LoadAsync();
if (!loadSettingsResult.IsSuccess || loadSettingsResult.Value == null) {
return loadSettingsResult.ToResultOfType<LoginResponse?>(_ => null);
}
var settings = loadSettingsResult.Value;
var userResult = settings.GetUserByName(requestData.Username);
if (!userResult.IsSuccess || userResult.Value == null)
return userResult.ToResultOfType<LoginResponse?>(_ => null);
var user = userResult.Value;
var validatePasswordResult = user.ValidatePassword(requestData.Password, _appSettings.Auth.Pepper);
if (!validatePasswordResult.IsSuccess)
return validatePasswordResult.ToResultOfType<LoginResponse?>(default);
if (!JwtGenerator.TryGenerateToken(new JWTTokenGenerateRequest {
Secret = _appSettings.Auth.Secret,
Issuer = _appSettings.Auth.Issuer,
Audience = _appSettings.Auth.Audience,
Expiration = _appSettings.Auth.Expiration,
UserId = user.Id.ToString(),
Username = user.Name,
}, out (string token, JWTTokenClaims claims)? tokenData, out string? errorMessage)) {
return Result<LoginResponse?>.InternalServerError(null, errorMessage);
}
var (token, claims) = tokenData.Value;
if (claims.IssuedAt == null || claims.ExpiresAt == null)
return Result<LoginResponse?>.InternalServerError(null, "Token claims are missing required fields.");
string refreshToken = JwtGenerator.GenerateRefreshToken();
var response = new LoginResponse {
TokenType = "Bearer",
Token = token,
ExpiresAt = claims.ExpiresAt.Value,
RefreshToken = refreshToken,
RefreshTokenExpiresAt = claims.IssuedAt.Value.AddDays(_appSettings.Auth.RefreshExpiration)
};
return Result<LoginResponse?>.Ok(response);
}
//public async Task<Result<LoginResponse?>> RefreshTokenAsync(RefreshTokenRequest requestData) {
// return await HandleTokenResponseAsync(() =>
// _identityDomainService.RefreshTokenAsync(requestData.RefreshToken));
//}
//private static async Task<Result<LoginResponse?>> HandleTokenResponseAsync(Func<Task<Result<JwtToken?>>> tokenOperation) {
// var jwtTokenResult = await tokenOperation();
// if (!jwtTokenResult.IsSuccess || jwtTokenResult.Value == null)
// return jwtTokenResult.ToResultOfType<LoginResponse?>(_ => null);
// var jwtToken = jwtTokenResult.Value;
// return Result<LoginResponse?>.Ok(new LoginResponse {
// TokenType = jwtToken.TokenType,
// Token = jwtToken.Token,
// ExpiresAt = jwtToken.ExpiresAt,
// RefreshToken = jwtToken.RefreshToken,
// RefreshTokenExpiresAt = jwtToken.RefreshTokenExpiresAt
// });
//}
//public async Task<Result> Logout(JwtTokenData jwtTokenData, LogoutRequest requestData) {
// var logoutResult = await _identityDomainService.LogoutAsync(jwtTokenData.Username, jwtTokenData.Token, requestData.LogoutFromAllDevices);
// return logoutResult;
//}
#endregion
}

View File

@ -0,0 +1,88 @@
using MaksIT.Core.Threading;
using MaksIT.LetsEncryptServer.Domain;
using MaksIT.LetsEncryptServer.Dto;
using MaksIT.Core.Extensions;
using MaksIT.Results;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.IO;
using System.Threading.Tasks;
namespace MaksIT.LetsEncryptServer.Services;
public interface ISettingsService {
Task<Result<Settings>> LoadAsync();
Task<Result> SaveAsync(Settings settings);
}
public class SettingsService : ISettingsService, IDisposable {
private readonly ILogger<SettingsService> _logger;
private readonly string _settingsPath;
private readonly LockManager _lockManager;
public SettingsService(
ILogger<SettingsService> logger,
IOptions<Configuration> appSettings
) {
_logger = logger;
_settingsPath = appSettings.Value.SettingsFile;
_lockManager = new LockManager();
}
#region Internal I/O
private async Task<Result<Settings>> LoadInternalAsync() {
try {
if (!File.Exists(_settingsPath))
return Result<Settings>.Ok(new Settings());
var json = await File.ReadAllTextAsync(_settingsPath);
var settingsDto = json.ToObject<SettingsDto>();
if (settingsDto == null)
return Result<Settings>.InternalServerError(new Settings(), "Settings file is invalid or empty.");
var settings = new Settings {
Init = settingsDto.Init,
Users = [.. settingsDto.Users.Select(userDto => new User(userDto.Id.ToGuid(), userDto.Name).SeltSaltedHash(userDto.Salt, userDto.Hash))]
};
return Result<Settings>.Ok(settings);
} catch (Exception ex) {
_logger.LogError(ex, "Error loading settings file.");
return Result<Settings>.InternalServerError(new Settings(), ex.Message);
}
}
private async Task<Result> SaveInternalAsync(Settings settings) {
try {
var settingsDto = new SettingsDto {
Init = settings.Init,
Users = [.. settings.Users.Select(u => new UserDto {
Id = u.Id.ToString(),
Name = u.Name,
Salt = u.Salt,
Hash = u.Hash
})]
};
await File.WriteAllTextAsync(_settingsPath, settingsDto.ToJson());
_logger.LogInformation("Settings file saved.");
return Result.Ok();
} catch (Exception ex) {
_logger.LogError(ex, "Error saving settings file.");
return Result.InternalServerError(ex.Message);
}
}
#endregion
public async Task<Result<Settings>> LoadAsync() {
return await _lockManager.ExecuteWithLockAsync(() => LoadInternalAsync());
}
public async Task<Result> SaveAsync(Settings settings) {
return await _lockManager.ExecuteWithLockAsync(() => SaveInternalAsync(settings));
}
public void Dispose() {
_lockManager.Dispose();
}
}

View File

@ -8,6 +8,18 @@
"AllowedHosts": "*", "AllowedHosts": "*",
"Configuration": { "Configuration": {
"SettingsFile": "/data/settings.json",
"Auth": {
"Secret": "",
"Issuer": "",
"Audience": "",
"Expiration": 60,
"RefreshExpiration": 120,
"Pepper": ""
},
"Production": "https://acme-v02.api.letsencrypt.org/directory", "Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory", "Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",

View File

@ -1948,6 +1948,7 @@
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -2035,6 +2036,7 @@
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.38.0",
@ -2280,6 +2282,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3164,6 +3167,7 @@
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -5256,6 +5260,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5265,6 +5270,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@ -5327,6 +5333,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -5405,7 +5412,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -5946,7 +5954,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
"integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.2", "version": "2.2.2",
@ -6020,6 +6029,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -6150,6 +6160,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6246,6 +6257,7 @@
"integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.6", "fdir": "^6.4.6",
@ -6336,6 +6348,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View File

@ -10,6 +10,7 @@ import { Toast } from './components/Toast'
import { UtilitiesPage } from './pages/UtilitiesPage' import { UtilitiesPage } from './pages/UtilitiesPage'
import { RegisterPage } from './pages/RegisterPage' import { RegisterPage } from './pages/RegisterPage'
import { LetsEncryptTermsOfServicePage } from './pages/LetsEncryptTermsOfServicePage' import { LetsEncryptTermsOfServicePage } from './pages/LetsEncryptTermsOfServicePage'
import { UserPage } from './pages/UserPage'
interface LayoutWrapperProps { interface LayoutWrapperProps {
@ -45,7 +46,14 @@ const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
} }
footer={ footer={
{ {
children: <p>&copy; {new Date().getFullYear()} <a href={import.meta.env.VITE_COMPANY_URL}>{import.meta.env.VITE_COMPANY}</a></p> children: <p>
&copy; {new Date().getFullYear()} <a
href={import.meta.env.VITE_COMPANY_URL}
target={'_blank'}
rel={'noopener noreferrer'}>
{import.meta.env.VITE_COMPANY}
</a>
</p>
} }
} }
>{children}</Layout> >{children}</Layout>
@ -90,25 +98,27 @@ const AppMap: AppMapType[] = [
routes: ['/terms-of-service'], routes: ['/terms-of-service'],
page: LetsEncryptTermsOfServicePage, page: LetsEncryptTermsOfServicePage,
linkArea: [LinkArea.SideMenu] linkArea: [LinkArea.SideMenu]
} },
// { {
// title: 'Login', title: 'Login',
// routes: ['/login'], routes: ['/login'],
// page: LoginScreen, page: LoginScreen,
// useAuth: false, useAuth: false,
// useLayout: false useLayout: false
// }, },
{
title: 'User',
routes: ['/user/:userId'],
page: UserPage
},
// { // {
// title: 'About', // title: 'About',
// routes: ['/about'], // routes: ['/about'],
// page: Home // page: Home
// }, // },
// {
// title: 'User',
// routes: ['/user/:userId'],
// page: Users
// },
// { // {
// title: 'Organizations', // title: 'Organizations',
// routes: ['/organizations', '/organization/:organizationId'], // routes: ['/organizations', '/organization/:organizationId'],
@ -221,14 +231,12 @@ const GetRoutes = () => {
<Route <Route
key={route} key={route}
path={route} path={route}
// element={useAuth element={useAuth
// ? <Authorization> ? <Authorization>
// {PageComponent} {PageComponent}
// <UserOffcanvas /> <UserOffcanvas />
// </Authorization> </Authorization>
// : PageComponent} : PageComponent}
element={PageComponent}
/> />
) )
}) })

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { FC, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '../redux/hooks' import { useAppDispatch, useAppSelector } from '../redux/hooks'
import { setIdentityFromLocalStorage } from '../redux/slices/identitySlice' import { setIdentityFromLocalStorage } from '../redux/slices/identitySlice'
@ -7,44 +7,36 @@ interface AuthorizationProps {
children: React.ReactNode; children: React.ReactNode;
} }
const Authorization = (props: AuthorizationProps) => { const Authorization: FC<AuthorizationProps> = (props) => {
const { children } = props const { children } = props
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { identity } = useAppSelector((state) => state.identity) const { identity } = useAppSelector((state) => state.identity)
const [loading, setLoading] = useState(true) const isTokenExpired = useMemo(() => {
if (!identity || !identity.refreshTokenExpiresAt)
return true
return new Date(identity.refreshTokenExpiresAt) < new Date()
}, [identity])
useEffect(() => { useEffect(() => {
// Load identity from local storage on mount
dispatch(setIdentityFromLocalStorage()) dispatch(setIdentityFromLocalStorage())
setLoading(false)
}, [dispatch]) }, [dispatch])
useEffect(() => { useEffect(() => {
if (!loading) { if (!identity || isTokenExpired) {
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) { // Optionally, pass the current location for redirect after login
navigate('/login', { replace: true }) navigate('/login', { replace: true, state: { from: location } })
}
} }
}, [identity, navigate, loading]) }, [identity, isTokenExpired, navigate, location])
// Render a simple loading spinner while loading (Tailwind v4 compatible) return identity && !isTokenExpired
if (loading) { ? children
return ( : <></>
<div className={'flex items-center justify-center h-screen w-screen bg-white'}>
<div className={'animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500'}></div>
</div>
)
}
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
return null
}
return <>{children}</>
} }
export { export { Authorization }
Authorization
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react' import React, { FC, useEffect, useState } from 'react'
import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest' import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest'
import { useAppDispatch, useAppSelector } from '../redux/hooks' import { useAppDispatch, useAppSelector } from '../redux/hooks'
import { login } from '../redux/slices/identitySlice' import { login } from '../redux/slices/identitySlice'
@ -6,7 +6,7 @@ import { useFormState } from '../hooks/useFormState'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from './editors' import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from './editors'
const LoginScreen: React.FC = () => { const LoginScreen: FC = () => {
const [use2FA, setUse2FA] = useState(false) const [use2FA, setUse2FA] = useState(false)
const [use2FARecovery, setUse2FARecovery] = useState(false) const [use2FARecovery, setUse2FARecovery] = useState(false)
@ -48,14 +48,6 @@ const LoginScreen: React.FC = () => {
dispatch(login(formState)) dispatch(login(formState))
} }
// Utility classes
const inputClasses =
'block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200'
const checkboxClasses =
'h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-200'
const buttonPrimaryClasses =
'w-full py-2 px-4 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-semibold'
return ( return (
<div className={'flex items-center justify-center min-h-screen bg-gray-100'}> <div className={'flex items-center justify-center min-h-screen bg-gray-100'}>
<div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'}> <div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'}>
@ -68,8 +60,8 @@ const LoginScreen: React.FC = () => {
<div className={'space-y-4'}> <div className={'space-y-4'}>
<div className={'space-y-4'}> <div className={'space-y-4'}>
<TextBoxComponent <TextBoxComponent
label={'Email'} label={'Username'}
placeholder={'Email...'} placeholder={'Username...'}
value={formState.username} value={formState.username}
onChange={(e) => handleInputChange('username', e.target.value)} onChange={(e) => handleInputChange('username', e.target.value)}
errorText={errors.username} errorText={errors.username}

View File

@ -3,12 +3,7 @@ import { setShowUserOffcanvas } from '../redux/slices/identitySlice'
const UserButton = () => { const UserButton = () => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
//const { identity } = useAppSelector(state => state.identity) const { identity } = useAppSelector(state => state.identity)
const identity = {
username: 'JohnDoe',
isGlobalAdmin: true
}
if (!identity) return <></> if (!identity) return <></>
@ -16,9 +11,7 @@ const UserButton = () => {
className={'bg-white text-blue-500 px-2 py-1 rounded'} className={'bg-white text-blue-500 px-2 py-1 rounded'}
onClick={() => { onClick={() => {
dispatch(setShowUserOffcanvas()) dispatch(setShowUserOffcanvas())
}}> }}>{identity.username}</button>
{`${identity.username} ${identity.isGlobalAdmin ? '(Global Admin)' : ''}`.trim()}
</button>
} }
export { export {

View File

@ -0,0 +1,8 @@
const EditIdentity = () => {
return <div>Edit Identity Form</div>
}
export {
EditIdentity
}

View File

@ -5,10 +5,11 @@ import { CacheAccount } from '../entities/CacheAccount'
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse' import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
import { deleteData, getData, postData } from '../axiosConfig' import { deleteData, getData, postData } from '../axiosConfig'
import { ApiRoutes, GetApiRoute } from '../AppMap' import { ApiRoutes, GetApiRoute } from '../AppMap'
import { formatISODateString } from '../functions' import { enumToArr, formatISODateString } from '../functions'
import { addToast } from '../components/Toast/addToast' import { addToast } from '../components/Toast/addToast'
import { Offcanvas } from '../components/Offcanvas' import { Offcanvas } from '../components/Offcanvas'
import { EditAccount } from './EditAccount' import { EditAccount } from './EditAccount'
import { ChallengeType } from '../entities/ChallengeType'
const Home: FC = () => { const Home: FC = () => {
@ -113,14 +114,12 @@ const Home: FC = () => {
</li> </li>
))} ))}
</ul> </ul>
<RadioGroupComponent <SelectBoxComponent
colspan={12} label={'Challenge Type'}
label={'LetsEncrypt Environment'} options={enumToArr(ChallengeType)
options={[ .map(ct => ({ value: ct.value, label: ct.displayValue }))
{ value: 'staging', label: 'Staging' }, .filter(ct => ct.value !== ChallengeType.dns01)}
{ value: 'production', label: 'Production' } value={acc.challengeType}
]}
value={acc.challengeType ? 'staging' : 'production'}
disabled={true} disabled={true}
/> />
<h3 className={'col-span-12'}>Hostnames:</h3> <h3 className={'col-span-12'}>Hostnames:</h3>
@ -148,11 +147,13 @@ const Home: FC = () => {
</li> </li>
))} ))}
</ul> </ul>
<SelectBoxComponent
label={'Environment'} <RadioGroupComponent
colspan={12}
label={'LetsEncrypt Environment'}
options={[ options={[
{ value: 'production', label: 'Production' }, { value: 'staging', label: 'Staging' },
{ value: 'staging', label: 'Staging' } { value: 'production', label: 'Production' }
]} ]}
value={acc.isStaging ? 'staging' : 'production'} value={acc.isStaging ? 'staging' : 'production'}
disabled={true} disabled={true}

View File

@ -117,16 +117,15 @@ const Register: FC<RegisterProps> = () => {
{formState.contacts.map((contact) => ( {formState.contacts.map((contact) => (
<li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}> <li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<span className={'col-span-10'}>{contact}</span> <span className={'col-span-10'}>{contact}</span>
<FieldContainer colspan={2}> <ButtonComponent
<ButtonComponent colspan={2}
onClick={() => { onClick={() => {
const updatedContacts = formState.contacts.filter(c => c !== contact) const updatedContacts = formState.contacts.filter(c => c !== contact)
handleInputChange('contacts', updatedContacts) handleInputChange('contacts', updatedContacts)
}} }}
> >
<TrashIcon /> <TrashIcon />
</ButtonComponent> </ButtonComponent>
</FieldContainer>
</li> </li>
))} ))}
</ul> </ul>
@ -170,18 +169,17 @@ const Register: FC<RegisterProps> = () => {
<h3 className={'col-span-12'}>Hostnames:</h3> <h3 className={'col-span-12'}>Hostnames:</h3>
<ul className={'col-span-12'}> <ul className={'col-span-12'}>
{formState.hostnames.map((hostname) => ( {formState.hostnames.map((hostname) => (
<li key={hostname} className={'grid grid-cols-12 gap-4 w-full'}> <li key={hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<span className={'col-span-10'}>{hostname}</span> <span className={'col-span-10'}>{hostname}</span>
<FieldContainer colspan={2}> <ButtonComponent
<ButtonComponent colspan={2}
onClick={() => { onClick={() => {
const updatedHostnames = formState.hostnames.filter(h => h !== hostname) const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
handleInputChange('hostnames', updatedHostnames) handleInputChange('hostnames', updatedHostnames)
}} }}
> >
<TrashIcon /> <TrashIcon />
</ButtonComponent> </ButtonComponent>
</FieldContainer>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -0,0 +1,10 @@
import { EditIdentity } from '../forms/EditIdentity'
const UserPage = () => {
return <EditIdentity />
}
export {
UserPage
}

View File

@ -5,7 +5,7 @@ import identityReducer from './slices/identitySlice'
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
loader: loaderReducer, loader: loaderReducer,
//identity: identityReducer, identity: identityReducer,
}, },
}) })

View File

@ -0,0 +1,11 @@
using MaksIT.Core.Abstractions.Webapi;
namespace Models.LetsEncryptServer.Identity.Login;
public class LoginRequest : RequestModelBase {
public required string Username { get; set; }
public required string Password { get; set; }
public string? TwoFactorCode { get; set; }
public string? TwoFactorRecoveryCode { get; set; }
}

View File

@ -0,0 +1,13 @@
using MaksIT.Core.Abstractions.Webapi;
namespace Models.LetsEncryptServer.Identity.Login;
public class LoginResponse : ResponseModelBase {
public required string TokenType { get; set; }
public required string Token { get; set; }
public required DateTime ExpiresAt { get; set; }
public required string RefreshToken { get; set; }
public required DateTime RefreshTokenExpiresAt { get; set; }
}

View File

@ -0,0 +1,8 @@
using MaksIT.Core.Abstractions.Webapi;
namespace Models.LetsEncryptServer.Identity.Login;
public class RefreshTokenRequest : RequestModelBase {
public required string RefreshToken { get; set; } // The refresh token used for renewing access
}

View File

@ -0,0 +1,8 @@
using MaksIT.Core.Abstractions.Webapi;
namespace Models.LetsEncryptServer.Identity.Logout;
public class LogoutRequest : RequestModelBase {
public bool LogoutFromAllDevices { get; set; }
}

View File

@ -11,7 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.2" /> <PackageReference Include="MaksIT.Core" Version="1.5.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -27,6 +27,7 @@ services:
volumes: volumes:
- D:\Compose\MaksIT.CertsUI\acme:/acme - D:\Compose\MaksIT.CertsUI\acme:/acme
- D:\Compose\MaksIT.CertsUI\cache:/cache - D:\Compose\MaksIT.CertsUI\cache:/cache
- D:\Compose\MaksIT.CertsUI\data:/data
- D:\Compose\MaksIT.CertsUI\tmp:/tmp - D:\Compose\MaksIT.CertsUI\tmp:/tmp
- D:\Compose\MaksIT.CertsUI\configMap\appsettings.json:/configMap/appsettings.json:ro - D:\Compose\MaksIT.CertsUI\configMap\appsettings.json:/configMap/appsettings.json:ro
- D:\Compose\MaksIT.CertsUI\secrets\appsecrets.json:/secrets/appsecrets.json:ro - D:\Compose\MaksIT.CertsUI\secrets\appsecrets.json:/secrets/appsecrets.json:ro