diff --git a/MySupplyChain.API/Controllers/AuthController.cs b/MySupplyChain.API/Controllers/AuthController.cs index 782c8ad..c879036 100644 --- a/MySupplyChain.API/Controllers/AuthController.cs +++ b/MySupplyChain.API/Controllers/AuthController.cs @@ -81,4 +81,23 @@ public async Task DeleteAccount() await mediator.Send(new DeleteAccountCommand(userId)); return NoContent(); } + + /// + /// Update user's username (display name) + /// + [Authorize] + [HttpPut("username")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task UpdateUsername([FromBody] UpdateUsernameDto dto) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(userId)) return Unauthorized(); + + var token = await mediator.Send(new MySupplyChain.Application.Auth.Commands.UpdateUsername.UpdateUsernameCommand(userId, dto.NewUsername)); + return Ok(new { Token = token, Message = "Username updated successfully" }); + } } + +public record UpdateUsernameDto(string NewUsername); diff --git a/MySupplyChain.API/Program.cs b/MySupplyChain.API/Program.cs index 6ba2f40..615bda6 100644 --- a/MySupplyChain.API/Program.cs +++ b/MySupplyChain.API/Program.cs @@ -69,9 +69,13 @@ }); builder.Services.AddDataProtection(); + builder.Services.AddHttpContextAccessor(); builder.Services.AddIdentityCore(options => { + // Require unique emails for all accounts + options.User.RequireUniqueEmail = true; + //Add spaces to the allowed user name characters options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+ "; @@ -149,23 +153,17 @@ // ─── Seed Database ───────────────────────────────────────────────────────── if (!IsIntegrationTestRun) { - using (var scope = app.Services.CreateScope()) + using var scope = app.Services.CreateScope(); + var services = scope.ServiceProvider; + try { - var services = scope.ServiceProvider; - try - { - var context = services.GetRequiredService(); - await context.Database.MigrateAsync(); - - var seederLogger = services.GetRequiredService>(); - var csvPath = Path.Combine(app.Environment.ContentRootPath, "..", "data", "Retail-Supply-Chain-Sales-Dataset.csv"); - await MySupplyChain.Infrastructure.Persistence.CsvDatabaseSeeder.SeedAsync(context, csvPath, seederLogger); - } - catch (Exception ex) - { - var seederLogger = services.GetRequiredService>(); - seederLogger.LogError(ex, "An error occurred while seeding the database."); - } + var context = services.GetRequiredService(); + await context.Database.MigrateAsync(); + } + catch (Exception ex) + { + var seederLogger = services.GetRequiredService>(); + seederLogger.LogError(ex, "An error occurred while migrating the database."); } } diff --git a/MySupplyChain.Application/Auth/Commands/DeleteAccount/DeleteAccountCommand.cs b/MySupplyChain.Application/Auth/Commands/DeleteAccount/DeleteAccountCommand.cs index 50ad74f..8de638c 100644 --- a/MySupplyChain.Application/Auth/Commands/DeleteAccount/DeleteAccountCommand.cs +++ b/MySupplyChain.Application/Auth/Commands/DeleteAccount/DeleteAccountCommand.cs @@ -9,8 +9,7 @@ public class DeleteAccountCommandHandler(IApplicationDbContext context, IAuthSer { public async Task Handle(DeleteAccountCommand request, CancellationToken cancellationToken) { - // 1. Wipe business data first - // In a real app, we'd filter these by UserId + // 1. Wipe business data first (scoped automatically by global query filters) context.SalesHistories.RemoveRange(context.SalesHistories); context.Orders.RemoveRange(context.Orders); context.Products.RemoveRange(context.Products); diff --git a/MySupplyChain.Application/Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs b/MySupplyChain.Application/Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs new file mode 100644 index 0000000..06c8f8a --- /dev/null +++ b/MySupplyChain.Application/Auth/Commands/UpdateUsername/UpdateUsernameCommand.cs @@ -0,0 +1,14 @@ +using MediatR; +using MySupplyChain.Application.Common.Interfaces; + +namespace MySupplyChain.Application.Auth.Commands.UpdateUsername; + +public record UpdateUsernameCommand(string UserId, string NewUsername) : IRequest; + +public class UpdateUsernameCommandHandler(IAuthService authService) : IRequestHandler +{ + public async Task Handle(UpdateUsernameCommand request, CancellationToken cancellationToken) + { + return await authService.UpdateUsernameAsync(request.UserId, request.NewUsername); + } +} diff --git a/MySupplyChain.Application/Auth/Commands/WipeUserData/WipeUserDataCommand.cs b/MySupplyChain.Application/Auth/Commands/WipeUserData/WipeUserDataCommand.cs index 57595b7..fb018cc 100644 --- a/MySupplyChain.Application/Auth/Commands/WipeUserData/WipeUserDataCommand.cs +++ b/MySupplyChain.Application/Auth/Commands/WipeUserData/WipeUserDataCommand.cs @@ -1,5 +1,4 @@ using MediatR; -using Microsoft.EntityFrameworkCore; using MySupplyChain.Application.Common.Interfaces; namespace MySupplyChain.Application.Auth.Commands.WipeUserData; @@ -10,8 +9,8 @@ public class WipeUserDataCommandHandler(IApplicationDbContext context) : IReques { public async Task Handle(WipeUserDataCommand request, CancellationToken cancellationToken) { - // In a real multi-tenant app, filter by UserId. - // For this single-user demo/prototype, we wipe the global tables. + // The global query filters automatically scope these collections + // to the current authenticated user's data. context.SalesHistories.RemoveRange(context.SalesHistories); context.Orders.RemoveRange(context.Orders); diff --git a/MySupplyChain.Application/Common/Interfaces/IAuthService.cs b/MySupplyChain.Application/Common/Interfaces/IAuthService.cs index 33df221..4d29716 100644 --- a/MySupplyChain.Application/Common/Interfaces/IAuthService.cs +++ b/MySupplyChain.Application/Common/Interfaces/IAuthService.cs @@ -26,6 +26,11 @@ public interface IAuthService /// Task DeleteAccountAsync(string userId); + /// + /// Updates the user's username and returns a new JWT token + /// + Task UpdateUsernameAsync(string userId, string newUsername); + string GenerateJwtToken(User user); } diff --git a/MySupplyChain.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs b/MySupplyChain.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs index 406b821..68c6d3a 100644 --- a/MySupplyChain.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs +++ b/MySupplyChain.Application/Orders/Queries/GetOrders/GetOrdersQuery.cs @@ -21,7 +21,7 @@ public async Task> Handle(GetOrdersQuery request, Cancella Id = o.Id, OrderNumber = o.OrderNumber, Date = o.OrderDate.ToString("MMM dd, HH:mm"), - Customer = o.Customer.Name, + Customer = o.Customer?.Name ?? "Unknown Customer", Items = o.Items.Sum(i => i.Quantity), Status = o.Status.ToString().ToLower(), Total = o.TotalAmount.ToString("C") diff --git a/MySupplyChain.Application/SalesHistories/Commands/ImportSalesHistory/ImportSalesHistoryCommandHandler.cs b/MySupplyChain.Application/SalesHistories/Commands/ImportSalesHistory/ImportSalesHistoryCommandHandler.cs index 5c92342..1b6fb84 100644 --- a/MySupplyChain.Application/SalesHistories/Commands/ImportSalesHistory/ImportSalesHistoryCommandHandler.cs +++ b/MySupplyChain.Application/SalesHistories/Commands/ImportSalesHistory/ImportSalesHistoryCommandHandler.cs @@ -2,7 +2,6 @@ using CsvHelper.Configuration; using MediatR; using Microsoft.EntityFrameworkCore; -using MySupplyChain.Application.Common.Exceptions; using MySupplyChain.Application.Common.Interfaces; using MySupplyChain.Domain.Entities; using System.Globalization; @@ -19,7 +18,7 @@ public async Task Handle(ImportSalesHistoryCommand request, Ca // Harden cache against duplicate SKUs in DB // We fetch the list first to avoid untranslatable GroupBy issues in EF Core var productsList = await context.Products - .Where(p => p.Sku != null && p.Sku != "") + .Where(p => p.Sku != "") .ToListAsync(cancellationToken); var productsCache = productsList @@ -28,7 +27,7 @@ public async Task Handle(ImportSalesHistoryCommand request, Ca // Cache customers by email to avoid duplicates var customersList = await context.Customers - .Where(c => c.Email != null) + .Where(c => c.Email != "") .ToListAsync(cancellationToken); var customersCache = customersList @@ -89,7 +88,10 @@ public async Task Handle(ImportSalesHistoryCommand request, Ca if (!string.IsNullOrEmpty(request.ProductPriceColumn) && headers.Contains(request.ProductPriceColumn)) { var priceStr = csv.GetField(request.ProductPriceColumn); - decimal.TryParse(priceStr, out price); + if (!decimal.TryParse(priceStr, out price)) + { + price = 0m; + } } product = new Product diff --git a/MySupplyChain.Benchmarks/Program.cs b/MySupplyChain.Benchmarks/Program.cs index db75e86..0e82438 100644 --- a/MySupplyChain.Benchmarks/Program.cs +++ b/MySupplyChain.Benchmarks/Program.cs @@ -88,14 +88,14 @@ public async Task Forecast_Fallback() private static float[] GenerateSeries(Random rng, int count) { var series = new float[count]; - float base_ = 50f; + var baseValue = 50f; for (int i = 0; i < count; i++) { // Trend + weekly seasonality + noise - base_ += rng.NextSingle() * 0.2f - 0.1f; + baseValue += rng.NextSingle() * 0.2f - 0.1f; var seasonal = 5f * MathF.Sin(2f * MathF.PI * i / 7f); var noise = (float)(rng.NextDouble() * 4 - 2); - series[i] = MathF.Max(0, base_ + seasonal + noise); + series[i] = MathF.Max(0, baseValue + seasonal + noise); } return series; } diff --git a/MySupplyChain.Domain/Entities/EntityBase.cs b/MySupplyChain.Domain/Entities/EntityBase.cs index a0f2309..3980f27 100644 --- a/MySupplyChain.Domain/Entities/EntityBase.cs +++ b/MySupplyChain.Domain/Entities/EntityBase.cs @@ -1,28 +1,33 @@ -/* - * Author: Austin Chima - * Base class for all domain entities. - */ - -namespace MySupplyChain.Domain.Entities; - -/// -/// Abstract base class for all domain entities with common properties -/// - -public abstract class EntityBase -{ - /// - /// Unique identifier for the entity - /// - public int Id { get; set; } - - /// - /// When the entity was created (UTC) - /// - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - /// - /// When the entity was last updated (UTC) - /// - public DateTime? UpdatedAt { get; set; } -} +/* + * Author: Austin Chima + * Base class for all domain entities. + */ + +namespace MySupplyChain.Domain.Entities; + +/// +/// Abstract base class for all domain entities with common properties +/// + +public abstract class EntityBase +{ + /// + /// Unique identifier for the entity + /// + public int Id { get; set; } + + /// + /// When the entity was created (UTC) + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the entity was last updated (UTC) + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// The owner user identifier for multi-tenancy + /// + public string? UserId { get; set; } +} diff --git a/MySupplyChain.Domain/Entities/Order.cs b/MySupplyChain.Domain/Entities/Order.cs index 8852c1e..1f5cd7c 100644 --- a/MySupplyChain.Domain/Entities/Order.cs +++ b/MySupplyChain.Domain/Entities/Order.cs @@ -10,6 +10,6 @@ public class Order : EntityBase public OrderStatus Status { get; set; } public decimal TotalAmount { get; set; } - public Customer Customer { get; set; } = null!; + public Customer? Customer { get; set; } = null!; public ICollection Items { get; set; } = new List(); } diff --git a/MySupplyChain.Infrastructure/Authentication/AuthService.cs b/MySupplyChain.Infrastructure/Authentication/AuthService.cs index 3430faa..17290ce 100644 --- a/MySupplyChain.Infrastructure/Authentication/AuthService.cs +++ b/MySupplyChain.Infrastructure/Authentication/AuthService.cs @@ -1,111 +1,129 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; -using MySupplyChain.Application.Common.Interfaces; -using MySupplyChain.Domain.Entities; - -namespace MySupplyChain.Infrastructure.Authentication; - -/// -/// Service for user authentication and JWT token generation -/// -public class AuthService( - UserManager userManager, - SignInManager signInManager, - IOptions jwtSettings) - : IAuthService -{ - private readonly JwtSettings _jwtSettings = jwtSettings.Value; - - public async Task RegisterAsync(string username, string email, string password) - { - // Check if user already exists - var existingUser = await userManager.FindByNameAsync(username) ?? await userManager.FindByEmailAsync(email); - if (existingUser != null) - { - throw new Application.Common.Exceptions.ValidationException(new Dictionary - { - { "User", ["User with this username or email already exists."] } - }); - } - - var user = new User - { - UserName = username, - Email = email, - Role = Domain.Enums.Role.User, - CreatedAt = DateTime.UtcNow - }; - - var result = await userManager.CreateAsync(user, password); - - if (!result.Succeeded) - { - var errors = result.Errors - .GroupBy(e => e.Code, e => e.Description) - .ToDictionary(g => g.Key, g => g.ToArray()); - - throw new Application.Common.Exceptions.ValidationException(errors); - } - - return user; - } - - public async Task LoginAsync(string usernameOrEmail, string password) - { - // Find user by username or email - var user = await userManager.FindByNameAsync(usernameOrEmail) ?? - await userManager.FindByEmailAsync(usernameOrEmail); - - if (user == null) - { - return null; - } - - // Verify password - var result = await signInManager.CheckPasswordSignInAsync(user, password, false); - - if (!result.Succeeded) - { - return null; - } - - // Generate JWT token - return GenerateJwtToken(user); - } - - public async Task DeleteAccountAsync(string userId) - { - var user = await userManager.FindByIdAsync(userId); - if (user != null) - { - await userManager.DeleteAsync(user); - } - } - - public string GenerateJwtToken(User user) - { - var claims = new[] - { - new Claim(ClaimTypes.NameIdentifier, user.Id), // Id is string now - new Claim(ClaimTypes.Name, user.UserName ?? ""), - new Claim(ClaimTypes.Email, user.Email ?? ""), - new Claim(ClaimTypes.Role, user.Role.ToString()) - }; - - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)); - var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - - var token = new JwtSecurityToken( - issuer: _jwtSettings.Issuer, - audience: _jwtSettings.Audience, - claims: claims, - expires: DateTime.UtcNow.AddMinutes(_jwtSettings.ExpiryMinutes), - signingCredentials: credentials - ); - - return new JwtSecurityTokenHandler().WriteToken(token); - } -} +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using MySupplyChain.Application.Common.Interfaces; +using MySupplyChain.Domain.Entities; + +namespace MySupplyChain.Infrastructure.Authentication; + +/// +/// Service for user authentication and JWT token generation. +/// Login is email-only; usernames are display names and may be duplicated. +/// +public class AuthService( + UserManager userManager, + SignInManager signInManager, + IOptions jwtSettings) + : IAuthService +{ + private readonly JwtSettings _jwtSettings = jwtSettings.Value; + + public async Task RegisterAsync(string username, string email, string password) + { + // Uniqueness is enforced on email only — usernames are display names + var existingUser = await userManager.FindByEmailAsync(email); + if (existingUser != null) + { + throw new Application.Common.Exceptions.ValidationException(new Dictionary + { + { "User", ["An account with this email address already exists."] } + }); + } + + var user = new User + { + UserName = username, + Email = email, + Role = Domain.Enums.Role.User, + CreatedAt = DateTime.UtcNow + }; + + var result = await userManager.CreateAsync(user, password); + + if (!result.Succeeded) + { + var errors = result.Errors + .GroupBy(e => e.Code, e => e.Description) + .ToDictionary(g => g.Key, g => g.ToArray()); + + throw new Application.Common.Exceptions.ValidationException(errors); + } + + return user; + } + + public async Task LoginAsync(string usernameOrEmail, string password) + { + // Resolve user by email only (usernames are not unique) + var user = await userManager.FindByEmailAsync(usernameOrEmail); + + if (user == null) + { + return null; + } + + // Verify password + var result = await signInManager.CheckPasswordSignInAsync(user, password, false); + + if (!result.Succeeded) + { + return null; + } + + // Generate JWT token + return GenerateJwtToken(user); + } + + public async Task DeleteAccountAsync(string userId) + { + var user = await userManager.FindByIdAsync(userId); + if (user != null) + { + await userManager.DeleteAsync(user); + } + } + + public async Task UpdateUsernameAsync(string userId, string newUsername) + { + var user = await userManager.FindByIdAsync(userId) ?? throw new Application.Common.Exceptions.NotFoundException(nameof(User), userId); + user.UserName = newUsername; + var result = await userManager.UpdateAsync(user); + + if (!result.Succeeded) + { + var errors = result.Errors + .GroupBy(e => e.Code, e => e.Description) + .ToDictionary(g => g.Key, g => g.ToArray()); + + throw new Application.Common.Exceptions.ValidationException(errors); + } + + return GenerateJwtToken(user); + } + + public string GenerateJwtToken(User user) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id), // Id is string now + new Claim(ClaimTypes.Name, user.UserName ?? ""), + new Claim(ClaimTypes.Email, user.Email ?? ""), + new Claim(ClaimTypes.Role, user.Role.ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: _jwtSettings.Issuer, + audience: _jwtSettings.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_jwtSettings.ExpiryMinutes), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/MySupplyChain.Infrastructure/DependencyInjection.cs b/MySupplyChain.Infrastructure/DependencyInjection.cs index 400aa46..e91b068 100644 --- a/MySupplyChain.Infrastructure/DependencyInjection.cs +++ b/MySupplyChain.Infrastructure/DependencyInjection.cs @@ -33,6 +33,9 @@ public static IServiceCollection AddInfrastructure( services.AddScoped(provider => provider.GetRequiredService()); + // Register IHttpContextAccessor for multi-tenancy context + services.AddHttpContextAccessor(); + // Register ML.NET Demand Forecaster via factory so ILogger can be injected var modelPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "MLModels", "sales_model.zip"); services.AddSingleton(provider => diff --git a/MySupplyChain.Infrastructure/MachineLearning/DemandForecaster.cs b/MySupplyChain.Infrastructure/MachineLearning/DemandForecaster.cs index 2fc05f3..a2d8037 100644 --- a/MySupplyChain.Infrastructure/MachineLearning/DemandForecaster.cs +++ b/MySupplyChain.Infrastructure/MachineLearning/DemandForecaster.cs @@ -126,17 +126,17 @@ private ForecastResult BuildFallbackForecast(int productId, List salesLis // Use Linear Regression to find the real trend line for sparse data int n = salesList.Count; - float sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + float sumX = 0, sumY = 0, sumXy = 0, sumX2 = 0; for (int i = 0; i < n; i++) { sumX += i; sumY += salesList[i]; - sumXY += i * salesList[i]; + sumXy += i * salesList[i]; sumX2 += i * i; } - float slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + float slope = (n * sumXy - sumX * sumY) / (n * sumX2 - sumX * sumX); float intercept = (sumY - slope * sumX) / n; // Calculate standard deviation of the residuals for confidence intervals diff --git a/MySupplyChain.Infrastructure/Migrations/20260528200000_AddMultiTenancy.cs b/MySupplyChain.Infrastructure/Migrations/20260528200000_AddMultiTenancy.cs new file mode 100644 index 0000000..fcc2a62 --- /dev/null +++ b/MySupplyChain.Infrastructure/Migrations/20260528200000_AddMultiTenancy.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MySupplyChain.Infrastructure.Migrations +{ + [Migration("20260528200000_AddMultiTenancy")] + public partial class AddMultiTenancy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Add UserId column to each business entity table + migrationBuilder.AddColumn( + name: "UserId", + table: "Products", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "SalesHistories", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "ReorderRequests", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Orders", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Customers", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserId", + table: "OrderItems", + type: "text", + nullable: true); + + // Recreate UserNameIndex as non-unique + migrationBuilder.DropIndex( + name: "UserNameIndex", + table: "AspNetUsers"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Drop the UserId columns + migrationBuilder.DropColumn( + name: "UserId", + table: "Products"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "SalesHistories"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "ReorderRequests"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Customers"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "OrderItems"); + + // Revert UserNameIndex to unique + migrationBuilder.DropIndex( + name: "UserNameIndex", + table: "AspNetUsers"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + } +} diff --git a/MySupplyChain.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/MySupplyChain.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 5de769b..a2c89dc 100644 --- a/MySupplyChain.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/MySupplyChain.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -184,6 +184,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UserId") + .HasColumnType("text"); + b.HasKey("Id"); b.ToTable("Customers"); @@ -219,6 +222,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UserId") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("CustomerId"); @@ -252,6 +258,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UserId") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("OrderId"); @@ -294,6 +303,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UserId") + .HasColumnType("text"); + b.HasKey("Id"); b.ToTable("Products"); @@ -332,6 +344,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UserId") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("ProductId"); @@ -369,6 +384,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("UserId") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("ProductId"); @@ -440,7 +458,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDatabaseName("EmailIndex"); b.HasIndex("NormalizedUserName") - .IsUnique() .HasDatabaseName("UserNameIndex"); b.ToTable("AspNetUsers", (string)null); diff --git a/MySupplyChain.Infrastructure/Persistence/ApplicationDbContext.cs b/MySupplyChain.Infrastructure/Persistence/ApplicationDbContext.cs index 62b98ca..b548f72 100644 --- a/MySupplyChain.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/MySupplyChain.Infrastructure/Persistence/ApplicationDbContext.cs @@ -1,3 +1,5 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using MySupplyChain.Application.Common.Interfaces; @@ -8,7 +10,9 @@ namespace MySupplyChain.Infrastructure.Persistence; /// /// EF Core implementation of the database context /// -public class ApplicationDbContext(DbContextOptions options) +public class ApplicationDbContext( + DbContextOptions options, + IHttpContextAccessor httpContextAccessor) : IdentityDbContext(options), IApplicationDbContext { public DbSet Products => Set(); @@ -19,6 +23,8 @@ public class ApplicationDbContext(DbContextOptions options public DbSet Customers => Set(); // User DbSet is inherited from IdentityDbContext + private string? CurrentUserId => httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); @@ -68,9 +74,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { // IdentityUser uses string Id by default. entity.Property(e => e.Role).IsRequired(); - // Identity handles UserName/Email uniqueness + // Allow duplicate usernames by making the NormalizedUserName index non-unique + entity.HasIndex(u => u.NormalizedUserName).IsUnique(false); }); + // Set global query filters for multi-tenancy + modelBuilder.Entity().HasQueryFilter(e => e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => e.UserId == CurrentUserId); + // SEED DATA var staticDate = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -115,4 +130,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } ); } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var currentUserId = CurrentUserId; + + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAt = DateTime.UtcNow; + if (string.IsNullOrEmpty(entry.Entity.UserId)) + { + entry.Entity.UserId = currentUserId; + } + break; + + case EntityState.Modified: + entry.Entity.UpdatedAt = DateTime.UtcNow; + break; + } + } + + return base.SaveChangesAsync(cancellationToken); + } } diff --git a/MySupplyChain.Infrastructure/Persistence/ApplicationDbContextFactory.cs b/MySupplyChain.Infrastructure/Persistence/ApplicationDbContextFactory.cs index 5431987..59887f3 100644 --- a/MySupplyChain.Infrastructure/Persistence/ApplicationDbContextFactory.cs +++ b/MySupplyChain.Infrastructure/Persistence/ApplicationDbContextFactory.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; @@ -30,6 +31,6 @@ public ApplicationDbContext CreateDbContext(string[] args) var optionsBuilder = new DbContextOptionsBuilder(); optionsBuilder.UseNpgsql(connectionString); - return new ApplicationDbContext(optionsBuilder.Options); + return new ApplicationDbContext(optionsBuilder.Options, new HttpContextAccessor()); } } \ No newline at end of file diff --git a/MySupplyChain.Tests/API/AuthTests.cs b/MySupplyChain.Tests/API/AuthTests.cs index f218670..1dab503 100644 --- a/MySupplyChain.Tests/API/AuthTests.cs +++ b/MySupplyChain.Tests/API/AuthTests.cs @@ -1,80 +1,81 @@ -using System.Net; -using System.Net.Http.Json; -using Microsoft.AspNetCore.Mvc.Testing; -using MySupplyChain.Application.Auth.Commands.Register; -using MySupplyChain.Application.Auth.Queries.Login; - -namespace MySupplyChain.Tests.API; - -public class AuthTests(WebApplicationFactory factory) : BaseIntegrationTest(factory) -{ - [Fact] - public async Task Register_ShouldCreateUser_WhenValidDataProvided() - { - // Arrange - var client = Factory.CreateClient(); - var command = new RegisterCommand("newuser", "new@example.com", "Password123!"); - - // Act - var response = await client.PostAsJsonAsync("/api/auth/register", command); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - var content = await response.Content.ReadAsStringAsync(); - content.Should().Contain("User registered successfully"); - } - - [Fact] - public async Task Register_ShouldReturnBadRequest_WhenUserAlreadyExists() - { - // Arrange - var client = Factory.CreateClient(); - var command = new RegisterCommand("duplicate", "duplicate@example.com", "Password123!"); - await client.PostAsJsonAsync("/api/auth/register", command); - - // Act - var response = await client.PostAsJsonAsync("/api/auth/register", command); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task Login_ShouldReturnToken_WhenCredentialsAreValid() - { - // Arrange - var client = Factory.CreateClient(); - var username = "logintest"; - var password = "Password123!"; - await client.PostAsJsonAsync("/api/auth/register", new RegisterCommand(username, "login@example.com", password)); - - var query = new LoginQuery(username, password); - - // Act - var response = await client.PostAsJsonAsync("/api/auth/login", query); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - var result = await response.Content.ReadFromJsonAsync(); - result?.Token.Should().NotBeNullOrEmpty(); - } - - private class LoginResponseDtO - { - public string Token { get; init; } = string.Empty; - } - - [Fact] - public async Task Login_ShouldReturnUnauthorized_WhenCredentialsAreInvalid() - { - // Arrange - var client = Factory.CreateClient(); - var query = new LoginQuery("nonexistent", "WrongPass123!"); - - // Act - var response = await client.PostAsJsonAsync("/api/auth/login", query); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - } -} +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using MySupplyChain.Application.Auth.Commands.Register; +using MySupplyChain.Application.Auth.Queries.Login; + +namespace MySupplyChain.Tests.API; + +public class AuthTests(WebApplicationFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task Register_ShouldCreateUser_WhenValidDataProvided() + { + // Arrange + var client = Factory.CreateClient(); + var command = new RegisterCommand("newuser", "new@example.com", "Password123!"); + + // Act + var response = await client.PostAsJsonAsync("/api/auth/register", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("User registered successfully"); + } + + [Fact] + public async Task Register_ShouldReturnBadRequest_WhenUserAlreadyExists() + { + // Arrange + var client = Factory.CreateClient(); + var command = new RegisterCommand("duplicate", "duplicate@example.com", "Password123!"); + await client.PostAsJsonAsync("/api/auth/register", command); + + // Act + var response = await client.PostAsJsonAsync("/api/auth/register", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Login_ShouldReturnToken_WhenCredentialsAreValid() + { + // Arrange + var client = Factory.CreateClient(); + var username = "logintest"; + var password = "Password123!"; + var email = "login@example.com"; + await client.PostAsJsonAsync("/api/auth/register", new RegisterCommand(username, email, password)); + + var query = new LoginQuery(email, password); + + // Act + var response = await client.PostAsJsonAsync("/api/auth/login", query); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + result?.Token.Should().NotBeNullOrEmpty(); + } + + private class LoginResponseDtO + { + public string Token { get; init; } = string.Empty; + } + + [Fact] + public async Task Login_ShouldReturnUnauthorized_WhenCredentialsAreInvalid() + { + // Arrange + var client = Factory.CreateClient(); + var query = new LoginQuery("nonexistent@example.com", "WrongPass123!"); + + // Act + var response = await client.PostAsJsonAsync("/api/auth/login", query); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/MySupplyChain.Tests/API/BaseIntegrationTest.cs b/MySupplyChain.Tests/API/BaseIntegrationTest.cs index a90ab7f..7c07836 100644 --- a/MySupplyChain.Tests/API/BaseIntegrationTest.cs +++ b/MySupplyChain.Tests/API/BaseIntegrationTest.cs @@ -86,7 +86,7 @@ protected async Task GetAuthenticatedClientAsync(string username = " // 2. Login to get token var loginResponse = await client.PostAsJsonAsync("/api/auth/login", new { - UsernameOrEmail = username, + UsernameOrEmail = $"{username}@example.com", Password = password }); diff --git a/MySupplyChain.Tests/Application/Auth/LoginQueryHandlerTests.cs b/MySupplyChain.Tests/Application/Auth/LoginQueryHandlerTests.cs index 9c87208..6fc1420 100644 --- a/MySupplyChain.Tests/Application/Auth/LoginQueryHandlerTests.cs +++ b/MySupplyChain.Tests/Application/Auth/LoginQueryHandlerTests.cs @@ -1,48 +1,48 @@ -using MySupplyChain.Application.Auth.Queries.Login; -using MySupplyChain.Application.Common.Interfaces; - -namespace MySupplyChain.Tests.Application.Auth; - -public class LoginQueryHandlerTests -{ - private readonly Mock _authServiceMock; - private readonly LoginQueryHandler _handler; - - public LoginQueryHandlerTests() - { - _authServiceMock = new Mock(); - _handler = new LoginQueryHandler(_authServiceMock.Object); - } - - [Fact] - public async Task Handle_ShouldReturnToken_WhenCredentialsAreCorrect() - { - // Arrange - var query = new LoginQuery("user", "Password123!"); - var expectedToken = "valid.jwt.token"; - - _authServiceMock.Setup(x => x.LoginAsync(query.UsernameOrEmail, query.Password)) - .ReturnsAsync(expectedToken); - - // Act - var result = await _handler.Handle(query, CancellationToken.None); - - // Assert - result.Should().Be(expectedToken); - } - - [Fact] - public async Task Handle_ShouldThrowUnauthorizedAccessException_WhenLoginFails() - { - // Arrange - var query = new LoginQuery("wrong", "bad"); - _authServiceMock.Setup(x => x.LoginAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync((string?)null); - - // Act - Func act = async () => await _handler.Handle(query, CancellationToken.None); - - // Assert - await act.Should().ThrowAsync(); - } -} +using MySupplyChain.Application.Auth.Queries.Login; +using MySupplyChain.Application.Common.Interfaces; + +namespace MySupplyChain.Tests.Application.Auth; + +public class LoginQueryHandlerTests +{ + private readonly Mock _authServiceMock; + private readonly LoginQueryHandler _handler; + + public LoginQueryHandlerTests() + { + _authServiceMock = new Mock(); + _handler = new LoginQueryHandler(_authServiceMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnToken_WhenCredentialsAreCorrect() + { + // Arrange + var query = new LoginQuery("user@example.com", "Password123!"); + var expectedToken = "valid.jwt.token"; + + _authServiceMock.Setup(x => x.LoginAsync(query.UsernameOrEmail, query.Password)) + .ReturnsAsync(expectedToken); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().Be(expectedToken); + } + + [Fact] + public async Task Handle_ShouldThrowUnauthorizedAccessException_WhenLoginFails() + { + // Arrange + var query = new LoginQuery("wrong@example.com", "bad"); + _authServiceMock.Setup(x => x.LoginAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string?)null); + + // Act + Func act = async () => await _handler.Handle(query, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/MySupplyChain.Tests/Application/Orders/CreateOrderCommandHandlerTests.cs b/MySupplyChain.Tests/Application/Orders/CreateOrderCommandHandlerTests.cs index 8dd1ec4..98173d6 100644 --- a/MySupplyChain.Tests/Application/Orders/CreateOrderCommandHandlerTests.cs +++ b/MySupplyChain.Tests/Application/Orders/CreateOrderCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using MySupplyChain.Application.Common.Exceptions; using MySupplyChain.Application.Common.Interfaces; @@ -20,7 +21,8 @@ public CreateOrderCommandHandlerTests() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; - _context = new ApplicationDbContext(options); + var httpContextAccessorMock = new Mock(); + _context = new ApplicationDbContext(options, httpContextAccessorMock.Object); _forecasterMock = new Mock(); _handler = new CreateOrderCommandHandler(_context, _forecasterMock.Object); diff --git a/MySupplyChain.UI/src/App.tsx b/MySupplyChain.UI/src/App.tsx index bba214c..5e6c57f 100644 --- a/MySupplyChain.UI/src/App.tsx +++ b/MySupplyChain.UI/src/App.tsx @@ -44,7 +44,7 @@ function LoginGate({ const [activeTab, setActiveTab] = useState<'signin' | 'signup'>('signin'); return ( -
+
{/* Dynamic Ambient Background Elements */}
@@ -60,24 +60,24 @@ function LoginGate({ {/* Cyberpunk matrix-grid overlay */}
{/* Inner Card Glow Lines */} -
-
+
+
{/* Logo & Header */}
-
- +
+ terminal
-

+

MySupplyChain Portal

@@ -89,7 +89,7 @@ function LoginGate({

{/* Animated background pill */}
+
warning {error}
@@ -141,14 +141,14 @@ function LoginGate({ >
{ e.preventDefault(); handleSignIn(); }} className="space-y-4">
- +
- person + mail setUsernameOrEmail(e.target.value)} - placeholder="e.g., administrator" + placeholder="you@company.com" className="w-full bg-[#0d0e12]/70 border border-outline-variant/30 focus:border-primary/50 rounded-xl py-3.5 pl-11 pr-4 text-sm text-on-surface placeholder:text-outline/40 focus:outline-none transition-all duration-200" required={activeTab === 'signin'} /> @@ -173,7 +173,7 @@ function LoginGate({
-
- - {/* Delete Account */} -
-
-

Delete Account Permanently

-

Destroy your entire profile, credentials, and all enterprise data.

-
- -
-
-
- -
- -
-
- - - {/* Reset Ledger Confirmation */} - setResetLedgerOpen(false)} - onConfirm={handleResetLedger} - title="Reset Business Ledger?" - message="This will clear your product catalog, sales history, and orders. Your account profile will remain intact for a fresh start." - confirmText={isProcessing ? "Resetting..." : "Confirm Reset"} - requireWord="RESET" - isDanger={true} - /> - - {/* Delete Account Confirmation */} - setDeleteAccountOpen(false)} - onConfirm={handleDeleteAccount} - title="Delete Profile & Data?" - message="This action is final. Your account credentials and all supply chain telemetry will be permanently purged from our servers." - confirmText={isProcessing ? "Deleting..." : "Delete Account"} - requireWord="DELETE" - isDanger={true} - /> - - ); -} +import Modal from "./Modal"; +import { useState } from "react"; +import ConfirmationModal from "./ConfirmationModal"; +import { auth } from "../lib/api"; +import type { TokenUser } from "../lib/auth"; + +interface AccountSettingsModalProps { + open: boolean; + onClose: () => void; + onDataReset?: () => void; + onUserUpdate?: () => void; + user: TokenUser | null; +} + +export default function AccountSettingsModal({ + open, + onClose, + onDataReset, + onUserUpdate, + user, +}: AccountSettingsModalProps) { + const [resetLedgerOpen, setResetLedgerOpen] = useState(false); + const [deleteAccountOpen, setDeleteAccountOpen] = useState(false); + + // Username edit states + const [isEditingUsername, setIsEditingUsername] = useState(false); + const [newUsername, setNewUsername] = useState(user?.username ?? ""); + const [usernameError, setUsernameError] = useState(null); + const [isSavingUsername, setIsSavingUsername] = useState(false); + + const initials = user + ? user.username + .split(/[\s._-]+/) + .map((w) => w[0]?.toUpperCase() ?? "") + .slice(0, 2) + .join("") || user.email[0]?.toUpperCase() || "?" + : "?"; + + const roleBadge = + user?.role === "Admin" ? "System Administrator" : "Team Member"; + + const handleResetLedger = async () => { + await auth.resetLedger(); + onDataReset?.(); + }; + + const handleDeleteAccount = async () => { + await auth.deleteAccount(); + localStorage.removeItem("supplychain_jwt"); + window.location.reload(); + }; + + const handleSaveUsername = async () => { + if (!newUsername.trim()) { + setUsernameError("Username cannot be empty"); + return; + } + setIsSavingUsername(true); + setUsernameError(null); + try { + const res = await auth.updateUsername(newUsername); + localStorage.setItem("supplychain_jwt", res.token); + onUserUpdate?.(); + setIsEditingUsername(false); + } catch (err) { + setUsernameError(err instanceof Error ? err.message : "Failed to update username"); + } finally { + setIsSavingUsername(false); + } + }; + + return ( + <> + +
+ {/* Profile Section */} +
+
+ {initials} +
+ + {isEditingUsername ? ( +
+
+ setNewUsername(e.target.value)} + disabled={isSavingUsername} + className="flex-1 bg-surface-container border border-outline-variant rounded-lg px-3 py-1.5 text-sm font-bold text-on-surface focus:outline-none focus:border-primary disabled:opacity-50" + autoFocus + /> + + +
+ {usernameError && ( +

{usernameError}

+ )} +
+ ) : ( +
+
+

+ {user?.username ?? "Unknown User"} +

+ +
+

+ {user?.email ?? "No email"} +

+ + {roleBadge} + +
+ )} +
+ + {/* Preferences */} +
+

+ General Preferences +

+
+
+
+ notifications_active +
Email Notifications
+
+
+
+
+
+
+
+ dark_mode +
Dark Mode Interface
+
+
+
+
+
+
+
+ + {/* Security / Danger Zone */} +
+

+ Data Security / Danger Zone +

+
+ {/* Reset Ledger */} +
+
+

Reset Business Ledger

+

Wipe all products and orders, but keep your login credentials.

+
+ +
+ + {/* Delete Account */} +
+
+

Delete Account Permanently

+

Destroy your entire profile, credentials, and all enterprise data.

+
+ +
+
+
+ +
+ +
+
+ + + {/* Reset Ledger Confirmation */} + setResetLedgerOpen(false)} + onConfirm={handleResetLedger} + title="Reset Business Ledger?" + message="This will clear your product catalog, sales history, and orders. Your account profile will remain intact for a fresh start." + confirmText="Confirm Reset" + requireWord="RESET" + isDanger={true} + /> + + {/* Delete Account Confirmation */} + setDeleteAccountOpen(false)} + onConfirm={handleDeleteAccount} + title="Delete Profile & Data?" + message="This action is final. Your account credentials and all supply chain telemetry will be permanently purged from our servers." + confirmText="Delete Account" + requireWord="DELETE" + isDanger={true} + /> + + ); +} diff --git a/MySupplyChain.UI/src/components/ConfirmationModal.tsx b/MySupplyChain.UI/src/components/ConfirmationModal.tsx index 009b5f3..8305900 100644 --- a/MySupplyChain.UI/src/components/ConfirmationModal.tsx +++ b/MySupplyChain.UI/src/components/ConfirmationModal.tsx @@ -1,80 +1,101 @@ -import Modal from "./Modal"; -import { useState } from "react"; - -interface ConfirmationModalProps { - open: boolean; - onClose: () => void; - onConfirm: () => void; - title: string; - message: string; - confirmText?: string; - requireWord?: string; - isDanger?: boolean; -} - -export default function ConfirmationModal({ - open, - onClose, - onConfirm, - title, - message, - confirmText = "Confirm", - requireWord, - isDanger = false, -}: ConfirmationModalProps) { - const [inputWord, setInputWord] = useState(""); - - const handleConfirm = () => { - if (requireWord && inputWord !== requireWord) return; - onConfirm(); - onClose(); - setInputWord(""); - }; - - const isConfirmDisabled = requireWord ? inputWord !== requireWord : false; - - return ( - -
-

- {message} -

- - {requireWord && ( -
- - setInputWord(e.target.value)} - placeholder={requireWord} - className="w-full bg-surface-container border border-outline-variant rounded-xl p-3 text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" - /> -
- )} - -
- - -
-
-
- ); -} +import Modal from "./Modal"; +import { useState } from "react"; + +interface ConfirmationModalProps { + open: boolean; + onClose: () => void; + onConfirm: () => void | Promise; + title: string; + message: string; + confirmText?: string; + requireWord?: string; + isDanger?: boolean; +} + +export default function ConfirmationModal({ + open, + onClose, + onConfirm, + title, + message, + confirmText = "Confirm", + requireWord, + isDanger = false, +}: ConfirmationModalProps) { + const [inputWord, setInputWord] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); + + const handleConfirm = async () => { + if (requireWord && inputWord !== requireWord) return; + setIsProcessing(true); + try { + await onConfirm(); + setInputWord(""); + onClose(); + } catch { + // Error handling is done by the caller; just stop the spinner + } finally { + setIsProcessing(false); + } + }; + + const handleClose = () => { + if (isProcessing) return; + setInputWord(""); + onClose(); + }; + + const isConfirmDisabled = (requireWord ? inputWord !== requireWord : false) || isProcessing; + + return ( + +
+

+ {message} +

+ + {requireWord && ( +
+ + setInputWord(e.target.value)} + placeholder={requireWord} + disabled={isProcessing} + className="w-full bg-surface-container border border-outline-variant rounded-xl p-3 text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50" + /> +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/MySupplyChain.UI/src/components/Layout.tsx b/MySupplyChain.UI/src/components/Layout.tsx index 1568827..b12dfab 100644 --- a/MySupplyChain.UI/src/components/Layout.tsx +++ b/MySupplyChain.UI/src/components/Layout.tsx @@ -1,8 +1,9 @@ -import { Outlet } from "react-router-dom"; +import { Outlet, useNavigate, useLocation } from "react-router-dom"; import Sidebar from "./Sidebar"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import SupportModal from "./SupportModal"; import AccountSettingsModal from "./AccountSettingsModal"; +import { getUserFromToken } from "../lib/auth"; /** * Root layout shell. Renders the Sidebar once and provides @@ -11,6 +12,22 @@ import AccountSettingsModal from "./AccountSettingsModal"; export default function Layout() { const [supportOpen, setSupportOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + + const [user, setUser] = useState(() => getUserFromToken()); + + const handleUserUpdate = useCallback(() => { + setUser(getUserFromToken()); + }, []); + + // Force the current page to re-mount by navigating away and back + const handleDataReset = useCallback(() => { + setSettingsOpen(false); + // Navigate to a temporary path then immediately back to trigger a full re-render + navigate("/dashboard", { replace: true }); + setTimeout(() => navigate(location.pathname, { replace: true }), 0); + }, [navigate, location.pathname]); return (
@@ -27,7 +44,13 @@ export default function Layout() {
setSupportOpen(false)} /> - setSettingsOpen(false)} /> + setSettingsOpen(false)} + onDataReset={handleDataReset} + onUserUpdate={handleUserUpdate} + user={user} + />
); } diff --git a/MySupplyChain.UI/src/lib/api.ts b/MySupplyChain.UI/src/lib/api.ts index a71b121..0522e84 100644 --- a/MySupplyChain.UI/src/lib/api.ts +++ b/MySupplyChain.UI/src/lib/api.ts @@ -95,6 +95,12 @@ export const auth = { request("/auth/account", { method: "DELETE", }), + + updateUsername: (newUsername: string) => + request("/auth/username", { + method: "PUT", + body: JSON.stringify({ newUsername }), + }), }; // ─── Products ────────────────────────────────────────────────────────────── diff --git a/MySupplyChain.UI/src/lib/auth.ts b/MySupplyChain.UI/src/lib/auth.ts index c574c2c..f764c4b 100644 --- a/MySupplyChain.UI/src/lib/auth.ts +++ b/MySupplyChain.UI/src/lib/auth.ts @@ -24,3 +24,29 @@ export function isAuthenticated(): boolean { return false; } } + +export interface TokenUser { + id: string; + username: string; + email: string; + role: string; +} + +export function getUserFromToken(): TokenUser | null { + const token = getToken(); + if (!token) return null; + + try { + const payload = JSON.parse(atob(token.split(".")[1])); + if (payload.exp * 1000 <= Date.now()) return null; + + return { + id: payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"] ?? "", + username: payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] ?? "", + email: payload["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] ?? "", + role: payload["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"] ?? "User", + }; + } catch { + return null; + } +} diff --git a/MySupplyChain.UI/src/pages/Orders.tsx b/MySupplyChain.UI/src/pages/Orders.tsx index d7a070f..0e85b07 100644 --- a/MySupplyChain.UI/src/pages/Orders.tsx +++ b/MySupplyChain.UI/src/pages/Orders.tsx @@ -45,7 +45,10 @@ export default function Orders() { }, []); useEffect(() => { - fetchOrders(); + const timer = setTimeout(() => { + fetchOrders(); + }, 0); + return () => clearTimeout(timer); }, [fetchOrders]); const handleDeleteOrder = async () => { @@ -55,6 +58,7 @@ export default function Orders() { setData(prev => prev.filter(o => o.id !== deleteId)); setDeleteId(null); } catch (err) { + console.error("Failed to delete order:", err); alert("Failed to delete order."); } }; @@ -64,6 +68,7 @@ export default function Orders() { await ordersApi.updateStatus(id, { id, status }); fetchOrders(); } catch (err) { + console.error("Failed to update status:", err); alert("Failed to update status."); } }; @@ -112,7 +117,7 @@ export default function Orders() { filter_list Status: {filter} -
+
{["All", "Processing", "Shipped", "Delivered", "Cancelled"].map(f => ( -
+
-
+
{["All", "Healthy", "Low Stock", "Out of Stock"].map(f => ( -
+