From 307c1b523af3c76dd458927a8cc811a0d355d7a1 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Wed, 27 Aug 2025 22:56:13 +0200 Subject: [PATCH 01/15] Switch to Cookie based Auth --- .gitignore | 1 + API/API.csproj | 1 + API/Constants/PolicyConstants.cs | 2 + API/Controllers/AuthController.cs | 37 ++++ API/Controllers/UserController.cs | 14 +- API/DTOs/OidcConfigurationDto.cs | 3 + API/DTOs/UserDto.cs | 1 + .../ApplicationServiceExtensions.cs | 15 ++ API/Extensions/ClaimsPrincipalExtensions.cs | 9 + API/Extensions/IdentityServiceExtensions.cs | 178 +++++++++++++++--- API/Helpers/KeyCloakRolesMapper.cs | 49 ----- API/Services/OidcService.cs | 114 +++++++++++ API/Services/Store/CustomTicketStore.cs | 49 +++++ API/Startup.cs | 2 +- UI/Web/package-lock.json | 14 -- UI/Web/package.json | 1 - UI/Web/src/app/_guards/role-guard.ts | 2 +- .../app/_interceptors/error-interceptor.ts | 3 +- UI/Web/src/app/_models/user.ts | 2 + UI/Web/src/app/_pipes/default-value.pipe.ts | 2 +- .../app/_pipes/delivert-state-tooltip-pipe.ts | 2 +- UI/Web/src/app/_pipes/delivery-state-pipe.ts | 2 +- .../src/app/_pipes/filter-comparison-pipe.ts | 2 +- UI/Web/src/app/_pipes/filter-field-pipe.ts | 4 +- UI/Web/src/app/_pipes/product-type-pipe.ts | 2 +- UI/Web/src/app/_pipes/stock-operation-pipe.ts | 2 +- .../src/app/_pipes/utc-to-local-time.pipe.ts | 4 +- UI/Web/src/app/_routes/oidc.routes.ts | 9 - UI/Web/src/app/_services/auth.service.ts | 160 +++------------- UI/Web/src/app/_services/client.service.ts | 8 +- UI/Web/src/app/_services/filter.service.ts | 2 +- UI/Web/src/app/_services/modal.service.ts | 2 +- UI/Web/src/app/_services/product.service.ts | 8 +- UI/Web/src/app/_services/transloco-loader.ts | 6 +- UI/Web/src/app/app.config.ts | 26 ++- UI/Web/src/app/app.routes.ts | 4 - UI/Web/src/app/app.ts | 1 - .../transition-delivery-modal.component.ts | 2 +- .../stock-history-modal.component.ts | 3 - UI/Web/src/app/filter/filter.component.ts | 2 - .../client-modal/client-modal.component.ts | 2 +- .../clients-table/clients-table.component.ts | 2 +- .../import-client-modal.component.ts | 4 - .../management-clients.component.ts | 4 - .../product-category-modal.component.ts | 11 +- .../product-modal/product-modal.component.ts | 11 +- .../management-products.component.ts | 2 +- .../management-server.component.ts | 2 +- UI/Web/src/app/nav-bar/nav-bar.component.html | 2 +- UI/Web/src/app/nav-bar/nav-bar.component.ts | 11 +- .../oidc-callback.component.html | 32 ---- .../oidc-callback.component.scss | 149 --------------- .../oidc-callback/oidc-callback.component.ts | 56 ------ .../confirm-modal/confirm-modal.component.ts | 2 +- .../loading-spinner.component.ts | 2 +- .../settings-item/settings-item.component.ts | 12 +- .../components/table/table.component.ts | 8 +- .../src/app/type-ahead/typeahead.component.ts | 20 +- UI/Web/src/main.ts | 6 +- 59 files changed, 509 insertions(+), 579 deletions(-) create mode 100644 API/Controllers/AuthController.cs delete mode 100644 API/Helpers/KeyCloakRolesMapper.cs create mode 100644 API/Services/OidcService.cs create mode 100644 API/Services/Store/CustomTicketStore.cs delete mode 100644 UI/Web/src/app/_routes/oidc.routes.ts delete mode 100644 UI/Web/src/app/oidc/oidc-callback/oidc-callback.component.html delete mode 100644 UI/Web/src/app/oidc/oidc-callback/oidc-callback.component.scss delete mode 100644 UI/Web/src/app/oidc/oidc-callback/oidc-callback.component.ts diff --git a/.gitignore b/.gitignore index 198476c..f299dbd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ API/obj API.Tests/bin API.Tests/obj API/config/logs/* +API/wwwroot .vscode appsettings.Development.json *.ai diff --git a/API/API.csproj b/API/API.csproj index 7f730cb..2e4bd8d 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -22,6 +22,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index 6d92b10..c5fc1bc 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -31,4 +31,6 @@ public static class PolicyConstants /// Grants user permission to change application settings /// public const string ManageApplication = nameof(ManageApplication); + + public static IList Roles = [CreateForOthers, HandleDeliveries, ViewAllDeliveries, ManageStock, ManageProducts, ManageClients, ManageApplication]; } \ No newline at end of file diff --git a/API/Controllers/AuthController.cs b/API/Controllers/AuthController.cs new file mode 100644 index 0000000..68a32e1 --- /dev/null +++ b/API/Controllers/AuthController.cs @@ -0,0 +1,37 @@ +using API.Extensions; +using API.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Route("[controller]")] +public class AuthController: ControllerBase +{ + + [AllowAnonymous] + [HttpGet("login")] + public IActionResult Login(string returnUrl = "/") + { + var properties = new AuthenticationProperties { RedirectUri = returnUrl }; + return Challenge(properties, IdentityServiceExtensions.OpenIdConnect); + } + + [AllowAnonymous] + [HttpGet("logout")] + public IActionResult Logout() + { + if (!Request.Cookies.ContainsKey(OidcService.CookieName)) + { + return Redirect("/"); + } + + return SignOut( + new AuthenticationProperties { RedirectUri = "/login" }, + CookieAuthenticationDefaults.AuthenticationScheme, + IdentityServiceExtensions.OpenIdConnect); + } + +} \ No newline at end of file diff --git a/API/Controllers/UserController.cs b/API/Controllers/UserController.cs index 65bb6be..267a808 100644 --- a/API/Controllers/UserController.cs +++ b/API/Controllers/UserController.cs @@ -1,7 +1,9 @@ using API.Data; using API.DTOs; +using API.Extensions; using API.Services; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -14,12 +16,22 @@ public async Task>> GetByIds(IList ids) { return Ok(await unitOfWork.UsersRepository.GetByIds(ids)); } + + [AllowAnonymous] + [HttpGet("has-cookie")] + public ActionResult HasCookie() + { + return Ok(Request.Cookies.ContainsKey(OidcService.CookieName)); + } [HttpGet] public async Task> CurrentUser() { var user = await userService.GetUser(User); - return Ok(mapper.Map(user)); + var dto = mapper.Map(user); + + dto.Roles = HttpContext.User.GetRoles(); + return Ok(dto); } [HttpGet("all")] diff --git a/API/DTOs/OidcConfigurationDto.cs b/API/DTOs/OidcConfigurationDto.cs index 1712aff..693426b 100644 --- a/API/DTOs/OidcConfigurationDto.cs +++ b/API/DTOs/OidcConfigurationDto.cs @@ -9,5 +9,8 @@ public sealed record OidcConfigurationDto [Required] public string ClientId { get; init; } + + [Required] + public string ClientSecret { get; init; } } \ No newline at end of file diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index a2c094a..166fb52 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -4,4 +4,5 @@ public class UserDto { public int Id { get; set; } public string Name { get; set; } + public IList Roles { get; set; } } \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index d197ba0..41ba3b8 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -3,8 +3,11 @@ using API.Data.Repositories; using API.Helpers; using API.Services; +using API.Services.Store; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; namespace API.Extensions; @@ -26,10 +29,13 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); services.AddPostgres(configuration); + services.AddRedis(configuration); + services.AddSingleton(); services.AddSwaggerGen(g => { @@ -37,6 +43,15 @@ public static void AddApplicationServices(this IServiceCollection services, ICon }); } + private static void AddRedis(this IServiceCollection services, IConfiguration configuration) + { + services.AddStackExchangeRedisCache(options => + { + options.Configuration = configuration.GetConnectionString("Redis"); + options.InstanceName = BuildInfo.AppName; + }); + } + private static void AddPostgres(this IServiceCollection services, IConfiguration configuration) { var pgConnectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING") diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index 3b49e45..c47c32e 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using API.Constants; namespace API.Extensions; @@ -14,5 +15,13 @@ public static string GetName(this ClaimsPrincipal principal) { return principal.FindFirst("name")?.Value ?? throw new UnauthorizedAccessException(); } + + public static IList GetRoles(this ClaimsPrincipal principal) + { + return principal.FindAll(ClaimTypes.Role) + .Where(x => PolicyConstants.Roles.Contains(x.Value)) + .Select(x => x.Value) + .ToList(); + } } \ No newline at end of file diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 18b7b0e..905acf3 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,10 +1,13 @@ using API.Constants; using API.DTOs; using API.Helpers; +using API.Services; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; -using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace API.Extensions; @@ -13,38 +16,111 @@ public static class IdentityServiceExtensions public const string OpenIdConnect = nameof(OpenIdConnect); - public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment) { - var openIdConnectConfig = configuration.GetSection(OpenIdConnect).Get(); if (openIdConnectConfig == null) throw new Exception("OpenIdConnect configuration is missing"); - services.AddTransient(); - services.AddAuthentication(OpenIdConnect) - .AddJwtBearer(OpenIdConnect, options => + services.AddSingleton>(_ => + { + var url = openIdConnectConfig.Authority + "/.well-known/openid-configuration"; + return new ConfigurationManager( + url, + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = url.StartsWith("https") } + ); + }); + services.AddSingleton(_ => openIdConnectConfig); + services.AddSingleton(); + + services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme) + .Configure(( + options, store) => { - options.Authority = openIdConnectConfig.Authority; - options.Audience = openIdConnectConfig.ClientId; - options.RequireHttpsMetadata = true; + options.ExpireTimeSpan = TimeSpan.FromDays(30); + options.SlidingExpiration = true; + + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.MaxAge = TimeSpan.FromDays(30); + options.SessionStore = store; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidAudience = openIdConnectConfig.ClientId, - ValidIssuer = openIdConnectConfig.Authority, + options.LoginPath = "/Auth/login"; + options.LogoutPath = "/Auth/logout"; - ValidateIssuer = true, - ValidateAudience = true, + if (environment.IsDevelopment()) + { + options.Cookie.Domain = null; + } - ValidateIssuerSigningKey = true, - RequireExpirationTime = true, - ValidateLifetime = true, - RequireSignedTokens = true + options.Events = new CookieAuthenticationEvents + { + OnValidatePrincipal = async ctx => + { + var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); + await oidcService.RefreshCookieToken(ctx); + }, + OnRedirectToAccessDenied = ctx => + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }, + OnRedirectToLogin = ctx => + { + if (ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/hubs")) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + } + return Task.CompletedTask; + } }; + }); - options.Events = new JwtBearerEvents + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme) + .AddOpenIdConnect(OpenIdConnect, options => + { + options.Authority = openIdConnectConfig.Authority; + options.ClientId = openIdConnectConfig.ClientId; + options.ClientSecret = openIdConnectConfig.ClientSecret; + options.RequireHttpsMetadata = options.Authority.StartsWith("https://"); + + options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.ResponseType = OpenIdConnectResponseType.Code; + options.CallbackPath = "/signin-oidc"; + options.SignedOutCallbackPath = "/signout-callback-oidc"; + + options.SaveTokens = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("offline_access"); + options.Scope.Add("roles"); + options.Scope.Add("email"); + + options.Events = new OpenIdConnectEvents { - OnMessageReceived = SetTokenFromQuery, + OnTokenValidated = OidcClaimsPrincipalConverter, + OnRedirectToIdentityProviderForSignOut = ctx => + { + if (!environment.IsDevelopment() && !string.IsNullOrEmpty(ctx.ProtocolMessage.PostLogoutRedirectUri)) + { + ctx.ProtocolMessage.PostLogoutRedirectUri = ctx.ProtocolMessage.PostLogoutRedirectUri.Replace("http://", "https://"); + } + + return Task.CompletedTask; + }, + OnRedirectToIdentityProvider = ctx => + { + if (!environment.IsDevelopment() && !string.IsNullOrEmpty(ctx.ProtocolMessage.RedirectUri)) + { + ctx.ProtocolMessage.RedirectUri = ctx.ProtocolMessage.RedirectUri.Replace("http://", "https://"); + } + + return Task.CompletedTask; + } }; }); @@ -66,15 +142,61 @@ private static AuthorizationBuilder AddPolicy(this AuthorizationBuilder builder, return builder.AddPolicy(roleName, policy => policy.RequireRole(roleName, roleName.ToLower(), roleName.ToUpper())); } + + private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx) + { + if (ctx.Principal == null) return; + + var userService = ctx.HttpContext.RequestServices.GetRequiredService(); + var oidcService = ctx.HttpContext.RequestServices.GetRequiredService(); + await userService.GetUser(ctx.Principal); // Ensure user is created + + var tokens = CopyOidcTokens(ctx); + ctx.Properties ??= new AuthenticationProperties(); + ctx.Properties.StoreTokens(tokens); + + + var idToken = ctx.Properties.GetTokenValue(OidcService.IdToken); + if (!string.IsNullOrEmpty(idToken)) + { + ctx.Principal = await oidcService.ParseIdToken(idToken); + } + + ctx.Success(); + } - private static Task SetTokenFromQuery(MessageReceivedContext context) + private static List CopyOidcTokens(TokenValidatedContext ctx) { - var accessToken = context.Request.Query["access_token"]; - var path = context.HttpContext.Request.Path; - // Only use query string based token on SignalR hubs - if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) context.Token = accessToken; + if (ctx.TokenEndpointResponse == null) + { + return []; + } - return Task.CompletedTask; + var tokens = new List(); + + if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.RefreshToken)) + { + tokens.Add(new AuthenticationToken { Name = OidcService.RefreshToken, Value = ctx.TokenEndpointResponse.RefreshToken }); + } + else + { + var logger = ctx.HttpContext.RequestServices.GetRequiredService>(); + logger.LogWarning("OIDC login without refresh token, automatic sync will not work for this user"); + } + + if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.IdToken)) + { + tokens.Add(new AuthenticationToken { Name = OidcService.IdToken, Value = ctx.TokenEndpointResponse.IdToken }); + } + + if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.ExpiresIn)) + { + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(ctx.TokenEndpointResponse.ExpiresIn)); + tokens.Add(new AuthenticationToken { Name = OidcService.ExpiresAt, Value = expiresAt.ToString("o") }); + } + + return tokens; } + } \ No newline at end of file diff --git a/API/Helpers/KeyCloakRolesMapper.cs b/API/Helpers/KeyCloakRolesMapper.cs deleted file mode 100644 index 97afa70..0000000 --- a/API/Helpers/KeyCloakRolesMapper.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Security.Claims; -using System.Text.Json; -using Microsoft.AspNetCore.Authentication; - -namespace API.Helpers; - -public class KeyCloakRolesMapper: IClaimsTransformation -{ - public async Task TransformAsync(ClaimsPrincipal principal) - { - if (principal.Identity == null || !principal.Identity.IsAuthenticated) - { - return principal; - } - - var identity = (ClaimsIdentity)principal.Identity; - var resourceAccess = identity.FindFirst("resource_access"); - if (resourceAccess == null || string.IsNullOrWhiteSpace(resourceAccess.Value)) - { - return principal; - } - - var resources = JsonSerializer.Deserialize>(resourceAccess.Value); - if (resources == null || resources.Count == 0) - { - return principal; - } - - // TODO: Get key from configuration - var resource = resources.GetValueOrDefault("in-out"); - if (resource?.roles == null) - { - return principal; - } - - foreach (var role in resource.roles) - { - identity.AddClaim(new Claim(ClaimTypes.Role, role)); - } - - return principal; - } - - private class Resource - { - // ReSharper disable once InconsistentNaming - public List? roles { get; set; } - } -} \ No newline at end of file diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs new file mode 100644 index 0000000..e947467 --- /dev/null +++ b/API/Services/OidcService.cs @@ -0,0 +1,114 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using API.DTOs; +using API.Extensions; +using Flurl.Http; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace API.Services; + +public interface IOidcService +{ + Task RefreshCookieToken(CookieValidatePrincipalContext ctx); + + Task ParseIdToken(string idToken); +} + +public class OidcService(ConfigurationManager oidcConfigurationManager, OidcConfigurationDto oidcConfiguration, ILogger logger): IOidcService +{ + public const string RefreshToken = "refresh_token"; + public const string IdToken = "id_token"; + public const string ExpiresAt = "expires_at"; + public const string CookieName = ".AspNetCore.Cookies"; + + private static readonly ConcurrentDictionary RefreshInProgress = new(); + + public async Task RefreshCookieToken(CookieValidatePrincipalContext ctx) + { + if (ctx.Principal == null) return; + + var key = ctx.Principal.GetUserId(); + + var refreshToken = ctx.Properties.GetTokenValue(RefreshToken); + if (string.IsNullOrEmpty(refreshToken)) return; + + var expiresAt = ctx.Properties.GetTokenValue(ExpiresAt); + if (string.IsNullOrEmpty(expiresAt)) return; + + var tokenExpiry = DateTimeOffset.ParseExact(expiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + if (tokenExpiry >= DateTimeOffset.UtcNow.AddSeconds(30)) return; + + // Ensure we're not refreshing twice + if (!RefreshInProgress.TryAdd(key, true)) return; + + try + { + var tokenResponse = await RefreshTokenAsync(refreshToken); + if (!string.IsNullOrEmpty(tokenResponse.Error)) + { + logger.LogError("Failed to refresh token : {Error} - {Description}", tokenResponse.Error, tokenResponse.ErrorDescription); + throw new UnauthorizedAccessException(); + } + + var principal = await ParseIdToken(tokenResponse.IdToken); + ctx.Principal = principal; + + var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(tokenResponse.ExpiresIn)); + ctx.Properties.UpdateTokenValue(ExpiresAt, newExpiresAt.ToString("o")); + ctx.Properties.UpdateTokenValue(RefreshToken, tokenResponse.RefreshToken); + ctx.Properties.UpdateTokenValue(IdToken, tokenResponse.IdToken); + ctx.ShouldRenew = true; + + logger.LogTrace("Automatically refreshed token for user {UserId}", ctx.Principal.GetUserId()); + } + finally + { + RefreshInProgress.TryRemove(key, out _); + } + } + + public async Task ParseIdToken(string idToken) + { + var discoveryDocument = await oidcConfigurationManager.GetConfigurationAsync(); + + var tokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = discoveryDocument.Issuer, + ValidAudience = oidcConfiguration.ClientId, + IssuerSigningKeys = discoveryDocument.SigningKeys, + ValidateIssuerSigningKey = true, + }; + + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(idToken, tokenValidationParameters, out _); + + return principal; + } + + private async Task RefreshTokenAsync(string refreshToken) + { + + var discoveryDocument = await oidcConfigurationManager.GetConfigurationAsync(); + + var msg = new + { + grant_type = RefreshToken, + refresh_token = refreshToken, + client_id = oidcConfiguration.ClientId, + client_secret = oidcConfiguration.ClientSecret, + }; + + var json = await discoveryDocument.TokenEndpoint + .AllowAnyHttpStatus() + .PostUrlEncodedAsync(msg) + .ReceiveString(); + + return new OpenIdConnectMessage(json); + } +} \ No newline at end of file diff --git a/API/Services/Store/CustomTicketStore.cs b/API/Services/Store/CustomTicketStore.cs new file mode 100644 index 0000000..ef1fc53 --- /dev/null +++ b/API/Services/Store/CustomTicketStore.cs @@ -0,0 +1,49 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Caching.Distributed; + +namespace API.Services.Store; + +public class CustomTicketStore(IDistributedCache cache, TicketSerializer ticketSerializer): ITicketStore +{ + public async Task StoreAsync(AuthenticationTicket ticket) + { + // Note: It might not be needed to make this cryptographic random, but better safe than sorry + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + var key = Convert.ToBase64String(bytes); + + await RenewAsync(key, ticket); + + return key; + } + + public async Task RenewAsync(string key, AuthenticationTicket ticket) + { + var options = new DistributedCacheEntryOptions(); + var expiresUtc = ticket.Properties.ExpiresUtc; + if (expiresUtc.HasValue) + { + options.AbsoluteExpiration = expiresUtc.Value; + } + else + { + options.SlidingExpiration = TimeSpan.FromDays(7); + } + + var data = ticketSerializer.Serialize(ticket); + await cache.SetAsync(key, data, options); + } + + public async Task RetrieveAsync(string key) + { + var data = await cache.GetAsync(key); + return data == null ? null : ticketSerializer.Deserialize(data); + } + + public async Task RemoveAsync(string key) + { + await cache.RemoveAsync(key); + } +} \ No newline at end of file diff --git a/API/Startup.cs b/API/Startup.cs index b293e3b..b95820c 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -27,7 +27,7 @@ public void ConfigureServices(IServiceCollection services) services.AddControllers(); services.AddCors(); - services.AddIdentityServices(cfg); + services.AddIdentityServices(cfg, env); services.AddSwaggerGen(opt => { opt.SwaggerDoc("v1", new OpenApiInfo diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index c286309..381842f 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -20,7 +20,6 @@ "@jsverse/transloco": "^7.6.1", "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.8", - "angular-oauth2-oidc": "^20.0.2", "bootstrap": "^5.3.6", "luxon": "^3.7.1", "ngx-toastr": "^19.0.0", @@ -4044,19 +4043,6 @@ "node": ">= 14.0.0" } }, - "node_modules/angular-oauth2-oidc": { - "version": "20.0.2", - "resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-20.0.2.tgz", - "integrity": "sha512-bMSXEQIuvgq8yqnsIatZggAvCJvY+pm7G8MK0tWCHR93UpFuNN+L5B6pY9CzRg8Ys+VVhkLIBx4zEHbJnv9icg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.5.2" - }, - "peerDependencies": { - "@angular/common": ">=20.0.0", - "@angular/core": ">=20.0.0" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index caecbef..c53cbdb 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -32,7 +32,6 @@ "@jsverse/transloco": "^7.6.1", "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.8", - "angular-oauth2-oidc": "^20.0.2", "bootstrap": "^5.3.6", "luxon": "^3.7.1", "ngx-toastr": "^19.0.0", diff --git a/UI/Web/src/app/_guards/role-guard.ts b/UI/Web/src/app/_guards/role-guard.ts index d6aa776..6dd2a09 100644 --- a/UI/Web/src/app/_guards/role-guard.ts +++ b/UI/Web/src/app/_guards/role-guard.ts @@ -1,4 +1,4 @@ -import { CanActivateFn } from '@angular/router'; +import {CanActivateFn} from '@angular/router'; import {AuthService, Role} from '../_services/auth.service'; import {inject} from '@angular/core'; import {filter, map} from 'rxjs'; diff --git a/UI/Web/src/app/_interceptors/error-interceptor.ts b/UI/Web/src/app/_interceptors/error-interceptor.ts index 4cd8a50..39e8231 100644 --- a/UI/Web/src/app/_interceptors/error-interceptor.ts +++ b/UI/Web/src/app/_interceptors/error-interceptor.ts @@ -2,7 +2,6 @@ import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/com import {inject, Injectable} from '@angular/core'; import {catchError, Observable} from 'rxjs'; import {TranslocoService} from '@jsverse/transloco'; -import {AuthService} from '../_services/auth.service'; import {ToastrService} from 'ngx-toastr'; @Injectable() @@ -129,7 +128,7 @@ export class ErrorInterceptor implements HttpInterceptor { } private handleAuthError(error: any) { - // TODO: Re-direct to logout + window.location.href = "/Auth/logout" } private toast(message: string, title?: string) { diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 2596f2a..60e5380 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -1,5 +1,7 @@ +import {Role} from '../_services/auth.service'; export type User = { id: number, name: string, + roles: Role[], } diff --git a/UI/Web/src/app/_pipes/default-value.pipe.ts b/UI/Web/src/app/_pipes/default-value.pipe.ts index 9c8b387..79eac31 100644 --- a/UI/Web/src/app/_pipes/default-value.pipe.ts +++ b/UI/Web/src/app/_pipes/default-value.pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; @Pipe({ name: 'defaultValue', diff --git a/UI/Web/src/app/_pipes/delivert-state-tooltip-pipe.ts b/UI/Web/src/app/_pipes/delivert-state-tooltip-pipe.ts index 58bf628..20507cd 100644 --- a/UI/Web/src/app/_pipes/delivert-state-tooltip-pipe.ts +++ b/UI/Web/src/app/_pipes/delivert-state-tooltip-pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {DeliveryState} from '../_models/delivery'; import {translate} from '@jsverse/transloco'; diff --git a/UI/Web/src/app/_pipes/delivery-state-pipe.ts b/UI/Web/src/app/_pipes/delivery-state-pipe.ts index ea7de6f..e0b1f5e 100644 --- a/UI/Web/src/app/_pipes/delivery-state-pipe.ts +++ b/UI/Web/src/app/_pipes/delivery-state-pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {DeliveryState} from '../_models/delivery'; import {translate} from '@jsverse/transloco'; diff --git a/UI/Web/src/app/_pipes/filter-comparison-pipe.ts b/UI/Web/src/app/_pipes/filter-comparison-pipe.ts index c30e7e2..4342319 100644 --- a/UI/Web/src/app/_pipes/filter-comparison-pipe.ts +++ b/UI/Web/src/app/_pipes/filter-comparison-pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {translate} from '@jsverse/transloco'; import {FilterComparison} from '../_models/filter'; diff --git a/UI/Web/src/app/_pipes/filter-field-pipe.ts b/UI/Web/src/app/_pipes/filter-field-pipe.ts index d89f244..b1e3d75 100644 --- a/UI/Web/src/app/_pipes/filter-field-pipe.ts +++ b/UI/Web/src/app/_pipes/filter-field-pipe.ts @@ -1,5 +1,5 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import { translate } from '@jsverse/transloco'; +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from '@jsverse/transloco'; import {FilterField} from '../_models/filter'; @Pipe({ diff --git a/UI/Web/src/app/_pipes/product-type-pipe.ts b/UI/Web/src/app/_pipes/product-type-pipe.ts index 1df5eda..616320b 100644 --- a/UI/Web/src/app/_pipes/product-type-pipe.ts +++ b/UI/Web/src/app/_pipes/product-type-pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {ProductType} from '../_models/product'; import {translate} from '@jsverse/transloco'; diff --git a/UI/Web/src/app/_pipes/stock-operation-pipe.ts b/UI/Web/src/app/_pipes/stock-operation-pipe.ts index 01649bc..6033bcb 100644 --- a/UI/Web/src/app/_pipes/stock-operation-pipe.ts +++ b/UI/Web/src/app/_pipes/stock-operation-pipe.ts @@ -1,4 +1,4 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {StockOperation} from '../_models/stock'; import {translate} from '@jsverse/transloco'; diff --git a/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts b/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts index 48bd5f2..5736b8f 100644 --- a/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts +++ b/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts @@ -2,8 +2,8 @@ * https://github.com/Kareadita/Kavita/blob/develop/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts */ -import { Pipe, PipeTransform } from '@angular/core'; -import { DateTime } from 'luxon'; +import {Pipe, PipeTransform} from '@angular/core'; +import {DateTime} from 'luxon'; type UtcToLocalTimeFormat = 'full' | 'short' | 'shortDate' | 'shortTime'; diff --git a/UI/Web/src/app/_routes/oidc.routes.ts b/UI/Web/src/app/_routes/oidc.routes.ts deleted file mode 100644 index f6f601d..0000000 --- a/UI/Web/src/app/_routes/oidc.routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {Routes} from "@angular/router"; -import {OidcCallbackComponent} from '../oidc/oidc-callback/oidc-callback.component'; - -export const routes: Routes = [ - { - path: 'callback', - component: OidcCallbackComponent, - } -] diff --git a/UI/Web/src/app/_services/auth.service.ts b/UI/Web/src/app/_services/auth.service.ts index 2ca2e84..dd5420c 100644 --- a/UI/Web/src/app/_services/auth.service.ts +++ b/UI/Web/src/app/_services/auth.service.ts @@ -1,20 +1,9 @@ -import {computed, DestroyRef, effect, inject, Injectable, Injector, runInInjectionContext, Signal, signal} from '@angular/core'; +import {computed, inject, Injectable, Signal, signal} from '@angular/core'; import {HttpClient} from '@angular/common/http'; -import {OAuthErrorEvent, OAuthService} from 'angular-oauth2-oidc'; import {environment} from '../../environments/environment'; -import {OidcConfiguration} from '../_models/configuration'; -import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop'; -import {from} from 'rxjs'; - -/** - * Enum mirror of angular-oauth2-oidc events which are used in Kavita - */ -export enum OidcEvents { - /** - * Fired on token refresh, and when the first token is received - */ - TokenRefreshed = "token_refreshed" -} +import {toObservable} from '@angular/core/rxjs-interop'; +import {catchError, map, of, switchMap, tap} from 'rxjs'; +import {User} from '../_models/user'; export enum Role { CreateForOthers = 'CreateForOthers', @@ -36,145 +25,52 @@ export type UserInfo = { }) export class AuthService { - private readonly oauth2 = inject(OAuthService); private readonly httpClient = inject(HttpClient); - private readonly destroyRef = inject(DestroyRef); - private readonly apiBaseUrl = environment.apiUrl; + private readonly baseUrl = environment.apiUrl; private readonly _loaded = signal(false); public readonly loaded = this._loaded.asReadonly(); public readonly loaded$ = toObservable(this.loaded); - /** - * Public OIDC settings - */ - private readonly _settings = signal(undefined); - public readonly settings = this._settings.asReadonly(); - - private readonly token = signal(''); - public readonly roles: Signal = computed(() => { - const settings = this.settings(); - const token = this.token(); - if (!token || !settings) return []; + const userInfo = this.userInfo(); + if (!userInfo) return []; - const claims = this.decodeJwt(token); - const resourceAccess = claims['resource_access']; - if (!resourceAccess || !resourceAccess[settings.clientId]) return []; - - const roles = resourceAccess[settings.clientId].roles; - return roles as Role[]; + return userInfo.roles; }); public readonly isAuthenticated= computed((): boolean => { - this.token(); // Retrigger for new tokens - return this.oauth2.hasValidAccessToken(); + return this.userInfo() !== undefined; }); - public readonly userInfo = computed(() => { - const token = this.token(); // Refresher - - const info = this.oauth2.getIdentityClaims(); - if (!info) return undefined; - return { - UserName: info["name"] - }; - }); + private readonly _userInfo = signal(undefined); + public readonly userInfo = this._userInfo.asReadonly(); constructor() { - this.setupRefresh(); - this.setupOAuth(); - } - - login() { - if (this.oauth2.hasValidAccessToken()) return; - - this.oauth2.initLoginFlow(); } - logout() { - this.oauth2.logOut(); - } - - private setupOAuth() { - // log events in dev - if (!environment.production) { - this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => { - if (event instanceof OAuthErrorEvent) { - console.error('OAuthErrorEvent:', event); - } else { - console.debug('OAuthEvent:', event); + loadUser() { + return this.httpClient.get(this.baseUrl + "user/has-cookie", {responseType: "text"}).pipe( + map(t => t === 'true'), + switchMap(hasCookie => { + if (!hasCookie) { + return of(false); } - }); - } - - this.oauth2.setStorage(localStorage); - - this.configuration().subscribe(cfg => { - this._settings.set(cfg); - - this.oauth2.configure({ - issuer: cfg.authority, - clientId: cfg.clientId, - // Require https in production unless localhost - requireHttps: environment.production ? 'remoteOnly' : false, - redirectUri: window.location.origin + "/oidc/callback", - postLogoutRedirectUri: window.location.origin, - showDebugInformation: !environment.production, - responseType: 'code', - scope: "openid profile email roles offline_access", - useSilentRefresh: false, - }); - this.oauth2.setupAutomaticSilentRefresh(); - - from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({ - next: _ => { - this._loaded.set(true); - - if (this.oauth2.hasValidAccessToken()) { - this.token.set(this.oauth2.getAccessToken()); - return; - } - - if (!this.oauth2.hasValidAccessToken() && this.oauth2.getRefreshToken()) { - this.oauth2.refreshToken() - .catch(e => console.error(e)) - .then(() => { - if (this.oauth2.hasValidAccessToken()) return; - this.login(); - }); - } else if (!this.oauth2.hasValidAccessToken()) { - this.login(); - } - }, - error: error => { - console.log(error); - } - }); - this.oauth2.events.subscribe(event => { - if (event.type === OidcEvents.TokenRefreshed && this.oauth2.hasValidAccessToken()) { - this.token.set(this.oauth2.getAccessToken()); - } - }); - - if (this.oauth2.hasValidAccessToken()) { - this.token.set(this.oauth2.getAccessToken()); - } - }); - } - - private setupRefresh() { - window.addEventListener('online', () => { - if (!this.oauth2.hasValidAccessToken() && this.oauth2.getRefreshToken()) { - this.oauth2.refreshToken().catch(e => console.error(e)); - } - }); + return this.httpClient.get(this.baseUrl + 'user/').pipe( + tap(user => { + this._userInfo.set(user); + this._loaded.set(true); + }), + map(() => true), + ); + }) + ); } - private configuration() { - return this.httpClient.get(this.apiBaseUrl + 'Configuration/oidc'); + logout() { + window.location.href = "/auth/logout"; } private decodeJwt(token: string) { diff --git a/UI/Web/src/app/_services/client.service.ts b/UI/Web/src/app/_services/client.service.ts index 69f5349..ff87b08 100644 --- a/UI/Web/src/app/_services/client.service.ts +++ b/UI/Web/src/app/_services/client.service.ts @@ -1,7 +1,7 @@ -import { inject, Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { environment } from '../../environments/environment'; -import { Observable } from 'rxjs'; +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {environment} from '../../environments/environment'; +import {Observable} from 'rxjs'; import {Client} from '../_models/client'; @Injectable({ diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts index 9a374a2..d089789 100644 --- a/UI/Web/src/app/_services/filter.service.ts +++ b/UI/Web/src/app/_services/filter.service.ts @@ -2,7 +2,7 @@ import {inject, Injectable} from '@angular/core'; import {FilterComparison, FilterField, FilterInputType} from '../_models/filter'; import {TypeaheadSettings} from '../type-ahead/typeahead.component'; import {ClientService} from './client.service'; -import {forkJoin, map, Observable, of, tap} from 'rxjs'; +import {map, Observable, of} from 'rxjs'; import {UserService} from './user.service'; import {ProductService} from './product.service'; import {DeliveryStatePipe} from '../_pipes/delivery-state-pipe'; diff --git a/UI/Web/src/app/_services/modal.service.ts b/UI/Web/src/app/_services/modal.service.ts index f8dd300..52c4ed3 100644 --- a/UI/Web/src/app/_services/modal.service.ts +++ b/UI/Web/src/app/_services/modal.service.ts @@ -2,7 +2,7 @@ import {inject, Injectable, TemplateRef, Type} from '@angular/core'; import {NgbModal, NgbModalOptions, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; import {ConfirmModalComponent} from '../shared/components/confirm-modal/confirm-modal.component'; import {DefaultModalOptions} from '../_models/default-modal-options'; -import {firstValueFrom, take} from 'rxjs'; +import {firstValueFrom} from 'rxjs'; import {translate} from '@jsverse/transloco'; @Injectable({ diff --git a/UI/Web/src/app/_services/product.service.ts b/UI/Web/src/app/_services/product.service.ts index c32e764..a723593 100644 --- a/UI/Web/src/app/_services/product.service.ts +++ b/UI/Web/src/app/_services/product.service.ts @@ -1,8 +1,8 @@ -import { inject, Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { environment } from '../../environments/environment'; +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {environment} from '../../environments/environment'; import {Product, ProductCategory} from '../_models/product'; -import { Observable } from 'rxjs'; +import {Observable} from 'rxjs'; @Injectable({ providedIn: 'root' diff --git a/UI/Web/src/app/_services/transloco-loader.ts b/UI/Web/src/app/_services/transloco-loader.ts index c939f0d..7cbbd6c 100644 --- a/UI/Web/src/app/_services/transloco-loader.ts +++ b/UI/Web/src/app/_services/transloco-loader.ts @@ -1,6 +1,6 @@ -import { inject, Injectable } from "@angular/core"; -import { Translation, TranslocoLoader } from "@jsverse/transloco"; -import { HttpClient } from "@angular/common/http"; +import {inject, Injectable} from "@angular/core"; +import {Translation, TranslocoLoader} from "@jsverse/transloco"; +import {HttpClient} from "@angular/common/http"; @Injectable({ providedIn: 'root' }) export class TranslocoHttpLoader implements TranslocoLoader { diff --git a/UI/Web/src/app/app.config.ts b/UI/Web/src/app/app.config.ts index 629144a..d29022d 100644 --- a/UI/Web/src/app/app.config.ts +++ b/UI/Web/src/app/app.config.ts @@ -1,20 +1,22 @@ import { ApplicationConfig, - importProvidersFrom, + importProvidersFrom, inject, + isDevMode, provideAppInitializer, provideBrowserGlobalErrorListeners, - provideZoneChangeDetection, isDevMode + provideZoneChangeDetection } from '@angular/core'; import {provideRouter} from '@angular/router'; -import {provideOAuthClient} from "angular-oauth2-oidc"; import {routes} from './app.routes'; import {HTTP_INTERCEPTORS, provideHttpClient, withFetch, withInterceptorsFromDi} from '@angular/common/http'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; -import { TranslocoHttpLoader } from './_services/transloco-loader'; -import { provideTransloco } from '@jsverse/transloco'; +import {TranslocoHttpLoader} from './_services/transloco-loader'; +import {provideTransloco} from '@jsverse/transloco'; import {provideToastr} from 'ngx-toastr'; import {ErrorInterceptor} from './_interceptors/error-interceptor'; import {DeliveryStatePipe} from './_pipes/delivery-state-pipe'; +import {AuthService} from './_services/auth.service'; +import {firstValueFrom} from 'rxjs'; export const appConfig: ApplicationConfig = { providers: [ @@ -24,11 +26,6 @@ export const appConfig: ApplicationConfig = { provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideToastr(), - provideOAuthClient({ - resourceServer: { - sendAccessToken: true, - } - }), { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }, provideHttpClient(withInterceptorsFromDi(), withFetch()), importProvidersFrom(BrowserAnimationsModule), @@ -46,5 +43,14 @@ export const appConfig: ApplicationConfig = { }, loader: TranslocoHttpLoader, }), + provideAppInitializer(async () => { + const authService = inject(AuthService); + const loggedIn = await firstValueFrom(authService.loadUser()); + if (!loggedIn) { + window.location.href = 'Auth/login'; + } + + return Promise.resolve(); + }), ] }; diff --git a/UI/Web/src/app/app.routes.ts b/UI/Web/src/app/app.routes.ts index 51b7ff8..e15adcc 100644 --- a/UI/Web/src/app/app.routes.ts +++ b/UI/Web/src/app/app.routes.ts @@ -68,8 +68,4 @@ export const routes: Routes = [ } ], }, - { - path: 'oidc', - loadChildren: () => import('./_routes/oidc.routes').then(m => m.routes) - } ]; diff --git a/UI/Web/src/app/app.ts b/UI/Web/src/app/app.ts index 5a77703..547c78f 100644 --- a/UI/Web/src/app/app.ts +++ b/UI/Web/src/app/app.ts @@ -1,7 +1,6 @@ import {Component, DestroyRef, HostListener, inject, OnInit} from '@angular/core'; import {NavigationStart, Router, RouterOutlet} from '@angular/router'; import {AuthService} from './_services/auth.service'; -import {DashboardComponent} from './dashboard/dashboard.component'; import {TranslocoModule, TranslocoService} from '@jsverse/transloco'; import {NavBarComponent} from './nav-bar/nav-bar.component'; import {NavigationService} from './_services/navigation.service'; diff --git a/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts b/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts index 29385c8..40f461c 100644 --- a/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts +++ b/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, Component, computed, inject, model, signal} from '@angular/core'; import {Delivery, DeliveryState} from '../../../_models/delivery'; -import {NgbActiveModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {LoadingSpinnerComponent} from '../../../shared/components/loading-spinner/loading-spinner.component'; import {translate, TranslocoDirective} from '@jsverse/transloco'; import {DeliveryStatePipe} from '../../../_pipes/delivery-state-pipe'; diff --git a/UI/Web/src/app/browse-stock/_components/stock-history-modal/stock-history-modal.component.ts b/UI/Web/src/app/browse-stock/_components/stock-history-modal/stock-history-modal.component.ts index 5c0c2cf..df27a8d 100644 --- a/UI/Web/src/app/browse-stock/_components/stock-history-modal/stock-history-modal.component.ts +++ b/UI/Web/src/app/browse-stock/_components/stock-history-modal/stock-history-modal.component.ts @@ -3,10 +3,8 @@ import {Stock, StockHistory} from '../../../_models/stock'; import {StockService} from '../../../_services/stock.service'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {LoadingSpinnerComponent} from '../../../shared/components/loading-spinner/loading-spinner.component'; -import {SettingsItemComponent} from '../../../shared/components/settings-item/settings-item.component'; import {TranslocoDirective} from '@jsverse/transloco'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; -import {BadgeComponent} from '../../../shared/components/badge/badge.component'; import {TableComponent} from '../../../shared/components/table/table.component'; import {UtcToLocalTimePipe} from '../../../_pipes/utc-to-local-time.pipe'; import {DefaultValuePipe} from '../../../_pipes/default-value.pipe'; @@ -19,7 +17,6 @@ import {StockOperationPipe} from '../../../_pipes/stock-operation-pipe'; ReactiveFormsModule, TranslocoDirective, LoadingSpinnerComponent, - BadgeComponent, TableComponent, UtcToLocalTimePipe, DefaultValuePipe, diff --git a/UI/Web/src/app/filter/filter.component.ts b/UI/Web/src/app/filter/filter.component.ts index 9873264..00ef5b3 100644 --- a/UI/Web/src/app/filter/filter.component.ts +++ b/UI/Web/src/app/filter/filter.component.ts @@ -23,7 +23,6 @@ import {TranslocoDirective} from '@jsverse/transloco'; import {SortFieldPipe} from '../_pipes/sort-field-pipe'; import {SettingsItemComponent} from '../shared/components/settings-item/settings-item.component'; import {UserService} from '../_services/user.service'; -import {AsyncPipe} from '@angular/common'; export type FilterStatementFormGroup = FormGroup<{ comparison: FormControl, @@ -41,7 +40,6 @@ export type FilterStatementFormGroup = FormGroup<{ TranslocoDirective, SortFieldPipe, SettingsItemComponent, - AsyncPipe ], templateUrl: './filter.component.html', styleUrl: './filter.component.scss', diff --git a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts index 916dfc4..ce84756 100644 --- a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts +++ b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts @@ -3,7 +3,7 @@ import {TranslocoDirective} from '@jsverse/transloco'; import {LoadingSpinnerComponent} from '../../../../shared/components/loading-spinner/loading-spinner.component'; import {Client} from '../../../../_models/client'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms'; -import {ModalDismissReasons, NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ClientService} from '../../../../_services/client.service'; import {SettingsItemComponent} from '../../../../shared/components/settings-item/settings-item.component'; import {DefaultValuePipe} from '../../../../_pipes/default-value.pipe'; diff --git a/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.ts b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.ts index c85ad5b..748e4c7 100644 --- a/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.ts +++ b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, ContentChild, input, TemplateRef, ViewChild} from '@angular/core'; +import {ChangeDetectionStrategy, Component, ContentChild, input, TemplateRef} from '@angular/core'; import {TableComponent} from '../../../../shared/components/table/table.component'; import {Client} from '../../../../_models/client'; import {TranslocoDirective} from '@jsverse/transloco'; diff --git a/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts index 3dfe792..5ef77c6 100644 --- a/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts +++ b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts @@ -12,8 +12,6 @@ import Papa from 'papaparse'; import {SettingsItemComponent} from '../../../../shared/components/settings-item/settings-item.component'; import {ClientFieldPipe} from '../../../../_pipes/client-field-pipe'; import {Client} from '../../../../_models/client'; -import {TableComponent} from '../../../../shared/components/table/table.component'; -import {DefaultValuePipe} from '../../../../_pipes/default-value.pipe'; import {ClientsTableComponent} from '../clients-table/clients-table.component'; enum StageId { @@ -50,8 +48,6 @@ type HeaderMappingControl = FormGroup<{ FileUploadComponent, SettingsItemComponent, ClientFieldPipe, - TableComponent, - DefaultValuePipe, ClientsTableComponent, ], templateUrl: './import-client-modal.component.html', diff --git a/UI/Web/src/app/management/management-clients/management-clients.component.ts b/UI/Web/src/app/management/management-clients/management-clients.component.ts index add2a3a..aa45132 100644 --- a/UI/Web/src/app/management/management-clients/management-clients.component.ts +++ b/UI/Web/src/app/management/management-clients/management-clients.component.ts @@ -4,10 +4,8 @@ import {ModalService} from '../../_services/modal.service'; import {Client} from '../../_models/client'; import {ClientService} from '../../_services/client.service'; import {LoadingSpinnerComponent} from '../../shared/components/loading-spinner/loading-spinner.component'; -import {TableComponent} from '../../shared/components/table/table.component'; import {ClientModalComponent} from './_components/client-modal/client-modal.component'; import {DefaultModalOptions} from '../../_models/default-modal-options'; -import {DefaultValuePipe} from '../../_pipes/default-value.pipe'; import {ImportClientModalComponent} from './_components/import-client-modal/import-client-modal.component'; import {ClientsTableComponent} from './_components/clients-table/clients-table.component'; @@ -16,8 +14,6 @@ import {ClientsTableComponent} from './_components/clients-table/clients-table.c imports: [ TranslocoDirective, LoadingSpinnerComponent, - TableComponent, - DefaultValuePipe, ClientsTableComponent, ], templateUrl: './management-clients.component.html', diff --git a/UI/Web/src/app/management/management-products/_components/product-category-modal/product-category-modal.component.ts b/UI/Web/src/app/management/management-products/_components/product-category-modal/product-category-modal.component.ts index 368e4f7..b3ea37d 100644 --- a/UI/Web/src/app/management/management-products/_components/product-category-modal/product-category-modal.component.ts +++ b/UI/Web/src/app/management/management-products/_components/product-category-modal/product-category-modal.component.ts @@ -1,13 +1,4 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DestroyRef, - inject, - input, - model, - OnInit, signal -} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model, OnInit, signal} from '@angular/core'; import {ProductCategory} from '../../../../_models/product'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ProductService} from '../../../../_services/product.service'; diff --git a/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts b/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts index 70038eb..23ef317 100644 --- a/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts +++ b/UI/Web/src/app/management/management-products/_components/product-modal/product-modal.component.ts @@ -1,13 +1,4 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - inject, - input, - model, - OnInit, - signal -} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model, OnInit, signal} from '@angular/core'; import {AllProductTypes, Product, ProductCategory, ProductType} from '../../../../_models/product'; import {ProductService} from '../../../../_services/product.service'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; diff --git a/UI/Web/src/app/management/management-products/management-products.component.ts b/UI/Web/src/app/management/management-products/management-products.component.ts index 7554e1e..fc4ac6a 100644 --- a/UI/Web/src/app/management/management-products/management-products.component.ts +++ b/UI/Web/src/app/management/management-products/management-products.component.ts @@ -12,7 +12,7 @@ import {ProductService} from '../../_services/product.service'; import {Product, ProductCategory} from '../../_models/product'; import {forkJoin} from 'rxjs'; import {TableComponent} from '../../shared/components/table/table.component'; -import {translate, TranslocoDirective} from '@jsverse/transloco'; +import {TranslocoDirective} from '@jsverse/transloco'; import {ProductCategoryModalComponent} from './_components/product-category-modal/product-category-modal.component'; import {DefaultModalOptions} from '../../_models/default-modal-options'; import {LoadingSpinnerComponent} from '../../shared/components/loading-spinner/loading-spinner.component'; diff --git a/UI/Web/src/app/management/management-server/management-server.component.ts b/UI/Web/src/app/management/management-server/management-server.component.ts index d9079a7..178fc7b 100644 --- a/UI/Web/src/app/management/management-server/management-server.component.ts +++ b/UI/Web/src/app/management/management-server/management-server.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import {ChangeDetectionStrategy, Component} from '@angular/core'; @Component({ selector: 'app-management-server', diff --git a/UI/Web/src/app/nav-bar/nav-bar.component.html b/UI/Web/src/app/nav-bar/nav-bar.component.html index 65567c4..7a98103 100644 --- a/UI/Web/src/app/nav-bar/nav-bar.component.html +++ b/UI/Web/src/app/nav-bar/nav-bar.component.html @@ -17,7 +17,7 @@ -
- @if (quantity > 0) { + @if (product.type === ProductType.Consumable) { +
+ @if (quantity > 0) { + + +
+ {{ quantity }} +
+ } + +
+ } @else { +
+ +
+ } -
- {{ quantity }} -
- } - - -
} } diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts index 26ed3ff..5db03ec 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts @@ -375,4 +375,13 @@ export class ManageDeliveryComponent implements OnInit { } protected readonly DeliveryState = DeliveryState; + + onToggleChange($event: Event, productId: number) { + const checkbox = $event.target as HTMLInputElement; + if (checkbox.checked) { + this.incrementProduct(productId); + } else { + this.decrementProduct(productId); + } + } } From 9606b61131cc6361a6c8c482d207f904f6470d61 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Wed, 27 Aug 2025 23:43:12 +0200 Subject: [PATCH 04/15] Proxy stuff with the new cookies --- UI/Web/angular.json | 5 ++++- UI/Web/proxy.conf.json | 10 ++++++++++ UI/Web/src/environments/environment.development.ts | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 UI/Web/proxy.conf.json diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 9cec628..7fb1478 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -82,7 +82,10 @@ "buildTarget": "Web:build:development" } }, - "defaultConfiguration": "development" + "defaultConfiguration": "development", + "options": { + "proxyConfig": "proxy.conf.json" + } }, "extract-i18n": { "builder": "@angular/build:extract-i18n" diff --git a/UI/Web/proxy.conf.json b/UI/Web/proxy.conf.json new file mode 100644 index 0000000..1dbbd9e --- /dev/null +++ b/UI/Web/proxy.conf.json @@ -0,0 +1,10 @@ +{ + "/api": { + "target": "http://localhost:5000", + "secure": false + }, + "/Auth": { + "target": "http://localhost:5000", + "secure": false + } +} diff --git a/UI/Web/src/environments/environment.development.ts b/UI/Web/src/environments/environment.development.ts index 04eafec..a3835a4 100644 --- a/UI/Web/src/environments/environment.development.ts +++ b/UI/Web/src/environments/environment.development.ts @@ -1,4 +1,4 @@ export const environment = { production: false, - apiUrl: 'http://localhost:5000/api/' + apiUrl: 'http://localhost:4300/api/' }; From 1193ba64899907c12aaef5531973d474d88befe5 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 00:06:29 +0200 Subject: [PATCH 05/15] BVDK-46 Support dates in filter --- .../Filter/ValueConverterExtension.cs | 4 ++-- UI/Web/src/app/_models/filter.ts | 13 +++++++++++-- UI/Web/src/app/filter/filter.component.html | 17 +++++++++++++++++ UI/Web/src/app/filter/filter.component.ts | 9 ++++++++- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/API/Extensions/Filter/ValueConverterExtension.cs b/API/Extensions/Filter/ValueConverterExtension.cs index e890ceb..c6b275b 100644 --- a/API/Extensions/Filter/ValueConverterExtension.cs +++ b/API/Extensions/Filter/ValueConverterExtension.cs @@ -15,8 +15,8 @@ public static object Convert(this FilterStatementDto filterStatement) FilterField.Recipient => ParseList(filterStatement.Value, int.Parse), FilterField.Lines => int.Parse(filterStatement.Value), FilterField.Products => ParseList(filterStatement.Value, int.Parse), - FilterField.Created => DateTime.Parse(filterStatement.Value, CultureInfo.InvariantCulture), - FilterField.LastModified => DateTime.Parse(filterStatement.Value, CultureInfo.InvariantCulture), + FilterField.Created => DateTime.Parse(filterStatement.Value, CultureInfo.InvariantCulture).ToUniversalTime(), + FilterField.LastModified => DateTime.Parse(filterStatement.Value, CultureInfo.InvariantCulture).ToUniversalTime(), _ => throw new ArgumentException($"Invalid field type: {filterStatement.Field}"), }; diff --git a/UI/Web/src/app/_models/filter.ts b/UI/Web/src/app/_models/filter.ts index 47d676a..d419d0d 100644 --- a/UI/Web/src/app/_models/filter.ts +++ b/UI/Web/src/app/_models/filter.ts @@ -69,9 +69,16 @@ export enum FilterInputType { Date = 3, } +function isDate(field: FilterField) { + return field === FilterField.Created || field === FilterField.LastModified; +} + export function serializeFilterToQuery(filter: Filter): string { const statements = filter.statements - .map(s => `${s.field},${s.comparison},${encodeURIComponent(s.value)}`) + .map(s => { + const value = isDate(s.field) ? JSON.stringify(s.value) : s.value; + return `${s.field},${s.comparison},${encodeURIComponent(value)}`; + }) .join(';'); const combination = FilterCombination[filter.combination]; @@ -103,10 +110,12 @@ export function deserializeFilterFromQuery(query: string): Filter { .filter(Boolean) .map(statement => { const [fieldStr, comparisonStr, valueEncoded] = statement.split(','); + const value = decodeURIComponent(valueEncoded); + return { field: Number(fieldStr) as FilterField, comparison: Number(comparisonStr) as FilterComparison, - value: decodeURIComponent(valueEncoded), + value: isDate(Number(fieldStr)) ? JSON.parse(value) : value, }; }); diff --git a/UI/Web/src/app/filter/filter.component.html b/UI/Web/src/app/filter/filter.component.html index d80cfd3..fc916e0 100644 --- a/UI/Web/src/app/filter/filter.component.html +++ b/UI/Web/src/app/filter/filter.component.html @@ -56,6 +56,23 @@ } } + @case (FilterInputType.Date) { +
+
+ + +
+
+ } } diff --git a/UI/Web/src/app/filter/filter.component.ts b/UI/Web/src/app/filter/filter.component.ts index 00ef5b3..7963295 100644 --- a/UI/Web/src/app/filter/filter.component.ts +++ b/UI/Web/src/app/filter/filter.component.ts @@ -23,6 +23,7 @@ import {TranslocoDirective} from '@jsverse/transloco'; import {SortFieldPipe} from '../_pipes/sort-field-pipe'; import {SettingsItemComponent} from '../shared/components/settings-item/settings-item.component'; import {UserService} from '../_services/user.service'; +import {NgbDate, NgbInputDatepicker} from '@ng-bootstrap/ng-bootstrap'; export type FilterStatementFormGroup = FormGroup<{ comparison: FormControl, @@ -40,6 +41,7 @@ export type FilterStatementFormGroup = FormGroup<{ TranslocoDirective, SortFieldPipe, SettingsItemComponent, + NgbInputDatepicker, ], templateUrl: './filter.component.html', styleUrl: './filter.component.scss', @@ -160,7 +162,12 @@ export class FilterComponent implements OnInit { const filter = this.filterForm.value as Filter; filter.statements.forEach(s => { - s.value = s.value+''; // force string + if (s.field === FilterField.Created || s.field === FilterField.LastModified) { + const date = s.value as unknown as NgbDate; + s.value = (new Date(date.year, date.month-1, date.day)).toISOString(); + } else { + s.value = s.value+''; // force string + } }); this.search.emit(filter); From 2e7ae0b96ef21d54461de4e0e0853c2a55b6892d Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 00:42:53 +0200 Subject: [PATCH 06/15] BVDK-48 re-order clients table --- .../clients-table.component.html | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.html b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.html index 7353244..5ffd55d 100644 --- a/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.html +++ b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.html @@ -8,19 +8,19 @@ {{t('header.address')}} - {{t('header.companyNumber')}} + {{t('header.contactName')}} - {{t('header.invoiceEmail')}} + {{t('header.contactNumber')}} - {{t('header.contactName')}} + {{t('header.contactEmail')}} - {{t('header.contactEmail')}} + {{t('header.invoiceEmail')}} - {{t('header.contactNumber')}} + {{t('header.companyNumber')}} @if (showActions()) { @@ -37,27 +37,27 @@ {{item.address | defaultValue}} - {{item.companyNumber | defaultValue}} + {{item.contactName | defaultValue}} - @if (item.invoiceEmail) { - {{item.invoiceEmail}} + {{item.contactNumber | defaultValue}} + + + @if (item.contactEmail) { + {{item.contactEmail}} } @else { {{null | defaultValue}} } - {{item.contactName | defaultValue}} - - - @if (item.contactEmail) { - {{item.contactEmail}} + @if (item.invoiceEmail) { + {{item.invoiceEmail}} } @else { {{null | defaultValue}} } - {{item.contactNumber | defaultValue}} + {{item.companyNumber | defaultValue}} @if (showActions()) { From dddcf25bca1dd40fe9690a76de22984f67962440 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:55:05 +0200 Subject: [PATCH 07/15] Disable metrics for metrics endpoint --- API/Startup.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API/Startup.cs b/API/Startup.cs index ae906d7..be3910e 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -200,7 +200,7 @@ public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, //endpoints.MapHub("hubs/messages"); //endpoints.MapHub("hubs/logs"); endpoints.MapFallbackToController("Index", "Fallback"); - endpoints.MapPrometheusScrapingEndpoint(); + endpoints.MapPrometheusScrapingEndpoint().DisableHttpMetrics(); }); applicationLifetime.ApplicationStarted.Register(() => From 0bcae2fd4e5c930130bbfb9142abb077869dbdaf Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:56:25 +0200 Subject: [PATCH 08/15] Don't allow everyone to move from Completed to In Progress --- API/Services/DeliveryService.cs | 2 +- .../transition-delivery-modal.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/API/Services/DeliveryService.cs b/API/Services/DeliveryService.cs index 82247c9..72d607d 100644 --- a/API/Services/DeliveryService.cs +++ b/API/Services/DeliveryService.cs @@ -235,12 +235,12 @@ private static List GetStateOptions(DeliveryState currentState, b case DeliveryState.Completed: var states = new List { - DeliveryState.InProgress, DeliveryState.Cancelled, }; if (canHandleDeliveries) { + states.Add(DeliveryState.InProgress); states.Add(DeliveryState.Handled); } diff --git a/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts b/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts index 40f461c..27dbc94 100644 --- a/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts +++ b/UI/Web/src/app/browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component.ts @@ -42,11 +42,11 @@ export class TransitionDeliveryModalComponent { case DeliveryState.InProgress: return [DeliveryState.Completed, DeliveryState.Cancelled]; case DeliveryState.Completed: - const states = [DeliveryState.InProgress, DeliveryState.Cancelled]; + const states = [DeliveryState.Cancelled]; if (canHandleDeliveries) { states.push(DeliveryState.Handled); + states.push(DeliveryState.InProgress) } - return states; case DeliveryState.Handled: return canHandleDeliveries ? [DeliveryState.Completed] : []; From c3860b8a31531342dac747ef6b3e3e280ea5813b Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:05:55 +0200 Subject: [PATCH 09/15] Fallback to memory cache, include xml comments --- API/API.csproj | 11 +++++++++++ API/Extensions/ApplicationServiceExtensions.cs | 13 ++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/API/API.csproj b/API/API.csproj index 2e4bd8d..96eb66e 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -10,6 +10,17 @@ In-Out + + false + ../favicon.ico + bin\$(Configuration)\$(AssemblyName).xml + + + + bin\$(Configuration)\$(AssemblyName).xml + 1701;1702;1591 + + diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 41ba3b8..64836d9 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -1,4 +1,5 @@ using System.IO.Abstractions; +using System.Reflection; using API.Data; using API.Data.Repositories; using API.Helpers; @@ -40,11 +41,22 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddSwaggerGen(g => { g.UseInlineDefinitionsForEnums(); + g.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, + $"{Assembly.GetExecutingAssembly().GetName().Name}.xml")); }); } private static void AddRedis(this IServiceCollection services, IConfiguration configuration) { + var redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") + ?? configuration.GetConnectionString("Redis"); + + if (string.IsNullOrWhiteSpace(redisConnectionString)) + { + services.AddDistributedMemoryCache(); + return; + } + services.AddStackExchangeRedisCache(options => { options.Configuration = configuration.GetConnectionString("Redis"); @@ -64,7 +76,6 @@ private static void AddPostgres(this IServiceCollection services, IConfiguration builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); }); options.EnableDetailedErrors(); - options.EnableSensitiveDataLogging(); options.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); }); From 35db9c956448b0599dfa8f796464816dd8db19fb Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:12:34 +0200 Subject: [PATCH 10/15] XML docs --- API/Controllers/AuthController.cs | 10 +++++- API/Controllers/ClientController.cs | 10 ++++++ API/Controllers/ConfigurationController.cs | 22 ------------- API/Controllers/DeliveryController.cs | 37 +++++++++++++++++++++- API/Controllers/FallbackController.cs | 2 ++ API/Controllers/ProductsController.cs | 15 +++++++++ API/Controllers/StockController.cs | 15 +++++++++ API/Controllers/UserController.cs | 22 +++++++++++++ 8 files changed, 109 insertions(+), 24 deletions(-) delete mode 100644 API/Controllers/ConfigurationController.cs diff --git a/API/Controllers/AuthController.cs b/API/Controllers/AuthController.cs index 68a32e1..05d4d4c 100644 --- a/API/Controllers/AuthController.cs +++ b/API/Controllers/AuthController.cs @@ -10,7 +10,11 @@ namespace API.Controllers; [Route("[controller]")] public class AuthController: ControllerBase { - + /// + /// Trigger OIDC login flow + /// + /// + /// [AllowAnonymous] [HttpGet("login")] public IActionResult Login(string returnUrl = "/") @@ -19,6 +23,10 @@ public IActionResult Login(string returnUrl = "/") return Challenge(properties, IdentityServiceExtensions.OpenIdConnect); } + /// + /// Trigger OIDC logout flow, if no auth cookie is found. Redirects to root + /// + /// [AllowAnonymous] [HttpGet("logout")] public IActionResult Logout() diff --git a/API/Controllers/ClientController.cs b/API/Controllers/ClientController.cs index d32df58..54774f5 100644 --- a/API/Controllers/ClientController.cs +++ b/API/Controllers/ClientController.cs @@ -11,6 +11,11 @@ namespace API.Controllers; public class ClientController(IUnitOfWork unitOfWork, IClientService clientService, IMapper mapper) : BaseApiController { + /// + /// Get clients by ids + /// + /// + /// [HttpPost("by-id")] public async Task>> GetClientDtosByIds(IList ids) { @@ -26,6 +31,11 @@ public async Task>> GetClientDtos() return Ok(await unitOfWork.ClientRepository.GetClientDtos()); } + /// + /// Search clients on name, contact name, and contact email + /// + /// + /// [HttpGet("search")] public async Task>> Search([FromQuery] string query) { diff --git a/API/Controllers/ConfigurationController.cs b/API/Controllers/ConfigurationController.cs deleted file mode 100644 index 8948a6f..0000000 --- a/API/Controllers/ConfigurationController.cs +++ /dev/null @@ -1,22 +0,0 @@ -using API.DTOs; -using API.Extensions; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace API.Controllers; - -public class ConfigurationController(IConfiguration configuration): BaseApiController -{ - - [AllowAnonymous] - [HttpGet("oidc")] - public ActionResult GetOidcConfiguration() - { - var openIdConnectConfig = configuration.GetSection(IdentityServiceExtensions.OpenIdConnect).Get(); - if (openIdConnectConfig == null) - throw new Exception("OpenIdConnect configuration is missing"); - - return Ok(openIdConnectConfig); - } - -} \ No newline at end of file diff --git a/API/Controllers/DeliveryController.cs b/API/Controllers/DeliveryController.cs index 33373d0..7e81b65 100644 --- a/API/Controllers/DeliveryController.cs +++ b/API/Controllers/DeliveryController.cs @@ -8,6 +8,7 @@ using API.Helpers; using API.Services; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -17,6 +18,11 @@ public class DeliveryController(ILogger logger, IUserService userService, IMapper mapper): BaseApiController { + /// + /// Get delivery by id + /// + /// + /// [HttpGet("{id}")] public async Task> Get(int id) { @@ -25,13 +31,20 @@ public class DeliveryController(ILogger logger, return Ok(delivery); } + /// + /// Filter deliveries + /// + /// + /// + /// + /// [HttpPost("filter")] public async Task> GetDeliveries([FromBody] FilterDto filter, [FromQuery] PaginationParams pagination) { var user = await unitOfWork.UsersRepository.GetByUserIdAsync(User.GetUserId()); if (user == null) { - logger.LogError($"User {User.GetUserId()} not found"); + logger.LogError("User {UserId} not found", User.GetUserId()); throw new UnauthorizedAccessException(); } @@ -56,6 +69,11 @@ public async Task> GetDeliveries([FromBody] FilterDto filter, return await unitOfWork.DeliveryRepository.GetDeliveries(filter, pagination); } + /// + /// Create a new delivery + /// + /// + /// [HttpPost] public async Task> Create(DeliveryDto dto) { @@ -65,6 +83,11 @@ public async Task> Create(DeliveryDto dto) return Ok(mapper.Map(delivery)); } + /// + /// Update an existing delivery + /// + /// + /// [HttpPut] public async Task Update(DeliveryDto dto) { @@ -72,6 +95,12 @@ public async Task Update(DeliveryDto dto) return Ok(mapper.Map(delivery)); } + /// + /// Change the state of a delivery + /// + /// + /// + /// [HttpPost("transition")] public async Task UpdateState([FromQuery] int deliveryId, [FromQuery] DeliveryState nextState) { @@ -79,7 +108,13 @@ public async Task UpdateState([FromQuery] int deliveryId, [FromQu return Ok(); } + /// + /// Fully delete a delivery, this is destructive. Consider transition to + /// + /// + /// [HttpDelete("{id}")] + [Authorize(PolicyConstants.HandleDeliveries)] public async Task Delete(int id) { await deliveryService.DeleteDelivery(id); diff --git a/API/Controllers/FallbackController.cs b/API/Controllers/FallbackController.cs index 922fb55..28584f6 100644 --- a/API/Controllers/FallbackController.cs +++ b/API/Controllers/FallbackController.cs @@ -1,9 +1,11 @@ using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; namespace API.Controllers; public class FallbackController: Controller { + [SwaggerIgnore] public PhysicalFileResult Index() { return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML"); diff --git a/API/Controllers/ProductsController.cs b/API/Controllers/ProductsController.cs index 044d425..accf817 100644 --- a/API/Controllers/ProductsController.cs +++ b/API/Controllers/ProductsController.cs @@ -11,6 +11,11 @@ namespace API.Controllers; public class ProductsController(IUnitOfWork unitOfWork, IProductService productService): BaseApiController { + /// + /// Get products by ids + /// + /// + /// [HttpPost("by-ids")] public async Task>> GetProductsByIds(IList ids) { @@ -28,6 +33,11 @@ public async Task>> GetProducts([FromQuery] bool return Ok(await unitOfWork.ProductRepository.GetAllDto(onlyEnabled)); } + /// + /// Returns all product categories + /// + /// + /// [HttpGet("category")] public async Task>> GetProductCategories([FromQuery] bool onlyEnabled = false) { @@ -134,6 +144,11 @@ public async Task DeleteCategory(int id) return Ok(); } + /// + /// Re-order categories + /// + /// Ids of the categories in the wanted order + /// [HttpPost("category/order")] [Authorize(Policy = PolicyConstants.ManageProducts)] public async Task OrderCategories(IList ids) diff --git a/API/Controllers/StockController.cs b/API/Controllers/StockController.cs index 338d33c..e0d4abc 100644 --- a/API/Controllers/StockController.cs +++ b/API/Controllers/StockController.cs @@ -12,18 +12,33 @@ namespace API.Controllers; public class StockController(ILogger logger, IUnitOfWork unitOfWork, IStockService stockService): BaseApiController { + /// + /// Retrieve change history + /// + /// + /// [HttpGet("history/{stockId}")] public async Task>> GetHistory(int stockId) { return Ok(await unitOfWork.StockRepository.GetHistoryDto(stockId)); } + /// + /// Returns all stock + /// + /// [HttpGet] public async Task>> GetStock() { return Ok(await unitOfWork.StockRepository.GetAllDtoAsync(StockIncludes.Product)); } + /// + /// Update stock + /// + /// + /// + /// [HttpPost] [Authorize(Policy = PolicyConstants.ManageStock)] public async Task UpdateStock(UpdateStockDto dto) diff --git a/API/Controllers/UserController.cs b/API/Controllers/UserController.cs index 267a808..560db37 100644 --- a/API/Controllers/UserController.cs +++ b/API/Controllers/UserController.cs @@ -11,12 +11,21 @@ namespace API.Controllers; public class UserController(IUnitOfWork unitOfWork, IUserService userService, IMapper mapper): BaseApiController { + /// + /// Get users by ids + /// + /// + /// [HttpPost("by-id")] public async Task>> GetByIds(IList ids) { return Ok(await unitOfWork.UsersRepository.GetByIds(ids)); } + /// + /// Returns true if the auth cookie is present, does not say anything about its validity + /// + /// [AllowAnonymous] [HttpGet("has-cookie")] public ActionResult HasCookie() @@ -24,6 +33,10 @@ public ActionResult HasCookie() return Ok(Request.Cookies.ContainsKey(OidcService.CookieName)); } + /// + /// Returns the current authenticated user, and its roles + /// + /// [HttpGet] public async Task> CurrentUser() { @@ -34,12 +47,21 @@ public async Task> CurrentUser() return Ok(dto); } + /// + /// Returns all users + /// + /// [HttpGet("all")] public async Task>> GetAll() { return Ok(await unitOfWork.UsersRepository.GetAll()); } + /// + /// Search for a specific user + /// + /// + /// [HttpGet("search")] public async Task>> Search([FromQuery] string query) { From 7ae04ca9d83e52b7ea68a06bb6dcfeaf72e1a842 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:14:47 +0200 Subject: [PATCH 11/15] Bump version --- API/API.csproj | 2 +- API/Constants/PolicyConstants.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/API/API.csproj b/API/API.csproj index 96eb66e..1a980d8 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -6,7 +6,7 @@ enable In-Out Brasserie Van De Kelder - 0.0.1 + 0.0.2 In-Out diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index c5fc1bc..4fa2cff 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -32,5 +32,5 @@ public static class PolicyConstants /// public const string ManageApplication = nameof(ManageApplication); - public static IList Roles = [CreateForOthers, HandleDeliveries, ViewAllDeliveries, ManageStock, ManageProducts, ManageClients, ManageApplication]; + public static readonly IList Roles = [CreateForOthers, HandleDeliveries, ViewAllDeliveries, ManageStock, ManageProducts, ManageClients, ManageApplication]; } \ No newline at end of file From 7116fe4aabdcd72e507f4f2936871b4b1aa77d14 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:19:56 +0200 Subject: [PATCH 12/15] Proxy, extra checks --- API/DTOs/OidcConfigurationDto.cs | 7 +++++++ API/Extensions/IdentityServiceExtensions.cs | 4 ++-- UI/Web/proxy.conf.json | 8 ++++++++ UI/Web/src/app/_services/auth.service.ts | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/API/DTOs/OidcConfigurationDto.cs b/API/DTOs/OidcConfigurationDto.cs index 693426b..a4e168f 100644 --- a/API/DTOs/OidcConfigurationDto.cs +++ b/API/DTOs/OidcConfigurationDto.cs @@ -13,4 +13,11 @@ public sealed record OidcConfigurationDto [Required] public string ClientSecret { get; init; } + public bool ValidConfig() + { + return !string.IsNullOrWhiteSpace(Authority) + && !string.IsNullOrWhiteSpace(ClientId) + && !string.IsNullOrWhiteSpace(ClientSecret); + } + } \ No newline at end of file diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 905acf3..6f9e61a 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -19,8 +19,8 @@ public static class IdentityServiceExtensions public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment) { var openIdConnectConfig = configuration.GetSection(OpenIdConnect).Get(); - if (openIdConnectConfig == null) - throw new Exception("OpenIdConnect configuration is missing"); + if (openIdConnectConfig == null || !openIdConnectConfig.ValidConfig()) + throw new Exception("OpenIdConnect configuration is missing or invalid"); services.AddSingleton>(_ => { diff --git a/UI/Web/proxy.conf.json b/UI/Web/proxy.conf.json index 1dbbd9e..0dbdc80 100644 --- a/UI/Web/proxy.conf.json +++ b/UI/Web/proxy.conf.json @@ -6,5 +6,13 @@ "/Auth": { "target": "http://localhost:5000", "secure": false + }, + "/signin-oidc": { + "target": "http://localhost:5000", + "secure": false + }, + "/signout-callback-oidc": { + "target": "http://localhost:5000", + "secure": false } } diff --git a/UI/Web/src/app/_services/auth.service.ts b/UI/Web/src/app/_services/auth.service.ts index dd5420c..59c50d5 100644 --- a/UI/Web/src/app/_services/auth.service.ts +++ b/UI/Web/src/app/_services/auth.service.ts @@ -70,7 +70,7 @@ export class AuthService { } logout() { - window.location.href = "/auth/logout"; + window.location.href = "/Auth/logout"; } private decodeJwt(token: string) { From 6f350c30452d3d946a6a42036470dd3a5e07b1e5 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:22:10 +0200 Subject: [PATCH 13/15] Don't redirect when no id token is avaible This won't happen in prod, as we'll use redis. But just in case for dev when using memory --- API/Controllers/AuthController.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/API/Controllers/AuthController.cs b/API/Controllers/AuthController.cs index 05d4d4c..1cdb4a8 100644 --- a/API/Controllers/AuthController.cs +++ b/API/Controllers/AuthController.cs @@ -29,12 +29,19 @@ public IActionResult Login(string returnUrl = "/") /// [AllowAnonymous] [HttpGet("logout")] - public IActionResult Logout() + public async Task Logout() { if (!Request.Cookies.ContainsKey(OidcService.CookieName)) { return Redirect("/"); } + + var res = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (!res.Succeeded || res.Properties == null || string.IsNullOrEmpty(res.Properties.GetString(OidcService.IdToken))) + { + HttpContext.Response.Cookies.Delete(OidcService.CookieName); + return Redirect("/"); + } return SignOut( new AuthenticationProperties { RedirectUri = "/login" }, From 65d6f3ff8356678484256267b00bfd5e07418a3e Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 18:50:41 +0200 Subject: [PATCH 14/15] BVDK-47: Fix transition popup not appearing after creating a delivery --- API/DTOs/UserDto.cs | 2 +- UI/Web/src/app/_services/user.service.ts | 8 +++++++- .../src/app/manage-delivery/manage-delivery.component.ts | 7 +++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 166fb52..73d5186 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -4,5 +4,5 @@ public class UserDto { public int Id { get; set; } public string Name { get; set; } - public IList Roles { get; set; } + public IList Roles { get; set; } = []; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/user.service.ts b/UI/Web/src/app/_services/user.service.ts index d1a11cd..c5b087e 100644 --- a/UI/Web/src/app/_services/user.service.ts +++ b/UI/Web/src/app/_services/user.service.ts @@ -2,6 +2,7 @@ import {inject, Injectable} from '@angular/core'; import {environment} from '../../environments/environment'; import {HttpClient} from '@angular/common/http'; import {User} from '../_models/user'; +import {map} from 'rxjs'; @Injectable({ providedIn: 'root' @@ -16,7 +17,12 @@ export class UserService { } search(query: string) { - return this.httpClient.get(this.baseUrl + '/search?query=' + query); + return this.httpClient.get(this.baseUrl + '/search?query=' + query).pipe( + map(users => users.map(u => { + u.roles ??= []; + return u; + })), + ); } getByIds(ids: number[]) { diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts index 5db03ec..a40f0e6 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts @@ -7,7 +7,7 @@ import {UserService} from '../_services/user.service'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {ActivatedRoute, Router, RouterLink} from '@angular/router'; import {DeliveryService} from '../_services/delivery.service'; -import {CommonModule} from '@angular/common'; +import {CommonModule, Location} from '@angular/common'; import {translate, TranslocoDirective} from '@jsverse/transloco'; import {TypeaheadComponent, TypeaheadSettings} from '../type-ahead/typeahead.component'; import {Client} from '../_models/client'; @@ -43,6 +43,7 @@ export class ManageDeliveryComponent implements OnInit { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly toastr = inject(ToastrService); + private readonly location = inject(Location); loading = signal(true); submitting = signal(false); @@ -122,7 +123,9 @@ export class ManageDeliveryComponent implements OnInit { effect(() => { const delivery = this.delivery(); if (delivery && delivery.id !== -1) { - this.router.navigateByUrl(`${this.router.url.split('?')[0]}?deliveryId=${delivery.id}`, { replaceUrl: true }); + const baseUrl = this.router.url.split('?')[0]; + const newUrl = `${baseUrl}?deliveryId=${delivery.id}`; + this.location.replaceState(newUrl); } }); } From e377ea9241eca31b86e3312c0ded659cc1f2cc25 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 18:59:05 +0200 Subject: [PATCH 15/15] BVDK-43: Refund deleted or cancelled deliveries --- API/Controllers/DeliveryController.cs | 2 +- API/Services/DeliveryService.cs | 38 ++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/API/Controllers/DeliveryController.cs b/API/Controllers/DeliveryController.cs index 7e81b65..f99aa6b 100644 --- a/API/Controllers/DeliveryController.cs +++ b/API/Controllers/DeliveryController.cs @@ -117,7 +117,7 @@ public async Task UpdateState([FromQuery] int deliveryId, [FromQu [Authorize(PolicyConstants.HandleDeliveries)] public async Task Delete(int id) { - await deliveryService.DeleteDelivery(id); + await deliveryService.DeleteDelivery(User, id); return Ok(); } diff --git a/API/Services/DeliveryService.cs b/API/Services/DeliveryService.cs index 72d607d..0e49161 100644 --- a/API/Services/DeliveryService.cs +++ b/API/Services/DeliveryService.cs @@ -15,7 +15,7 @@ public interface IDeliveryService { Task CreateDelivery(int userId, DeliveryDto dto); Task UpdateDelivery(ClaimsPrincipal actor, DeliveryDto dto); - Task DeleteDelivery(int id); + Task DeleteDelivery(ClaimsPrincipal actor, int id); Task TransitionDelivery(ClaimsPrincipal actor, int deliveryId, DeliveryState nextState); } @@ -193,21 +193,25 @@ public async Task UpdateDelivery(ClaimsPrincipal actor, DeliveryDto dt return delivery; } - public async Task DeleteDelivery(int id) + public async Task DeleteDelivery(ClaimsPrincipal actor, int id) { - var delivery = await unitOfWork.DeliveryRepository.GetDeliveryById(id); + var delivery = await unitOfWork.DeliveryRepository.GetDeliveryById(id, DeliveryIncludes.Complete); if (delivery == null) throw new InOutException("errors.delivery-not-found"); - + + await RefundDelivery(await userService.GetUser(actor), delivery); + unitOfWork.DeliveryRepository.Remove(delivery); await unitOfWork.CommitAsync(); } public async Task TransitionDelivery(ClaimsPrincipal actor, int deliveryId, DeliveryState nextState) { + var user = await userService.GetUser(actor); + var canHandleDeliveries = actor.IsInRole(PolicyConstants.HandleDeliveries); - var delivery = await unitOfWork.DeliveryRepository.GetDeliveryById(deliveryId); + var delivery = await unitOfWork.DeliveryRepository.GetDeliveryById(deliveryId, DeliveryIncludes.Complete); if (delivery == null) throw new InOutException("errors.delivery-not-found"); @@ -219,18 +223,38 @@ public async Task TransitionDelivery(ClaimsPrincipal actor, int deliveryId, Deli if (delivery.State == DeliveryState.Cancelled) { - // TODO: Refund stock + await RefundDelivery(user, delivery); } await unitOfWork.CommitAsync(); } + + /// + /// Return all items to stock + /// + /// + /// Load + private async Task RefundDelivery(User user, Delivery delivery) + { + var reference = $"Automatic refund after delivery cancellation by {user.Name} "; + var note = $"Original delivery by {delivery.From.Name} to {delivery.Recipient.Name}"; + + await stockService.UpdateStockBulkAsync(user, delivery.Lines.Select(l => new UpdateStockDto + { + ProductId = l.ProductId, + Value = l.Quantity, + Operation = StockOperation.Add, + Reference = reference, + Notes = note, + }).ToList()); + } private static List GetStateOptions(DeliveryState currentState, bool canHandleDeliveries) { switch (currentState) { case DeliveryState.InProgress: - return [DeliveryState.Completed, DeliveryState.Cancelled,]; + return [DeliveryState.Completed, DeliveryState.Cancelled]; case DeliveryState.Completed: var states = new List