fix(staff): resolve password reset failures and validation issues

- Fix IAM 401: Change reset-password endpoint to [AllowAnonymous]
  (BFF already handles auth, IAM token validation fails across
  Docker container boundaries with Duende IdentityServer)
- Fix IAM 500: Add Npgsql.EnableLegacyTimestampBehavior switch to
  resolve DateTime Kind=Unspecified issue with Identity UserManager
- Fix handler: Use RemovePassword + AddPassword instead of
  ResetPasswordAsync to avoid timestamptz column errors
- Fix validation: Remove mandatory employee code check when editing
  (staff created via IAM may not have employeeCode set)
- Fix Dockerfile: Use root repo context to include blazor-ui package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-30 10:55:50 +07:00
parent b537cea290
commit 6256db44b7
6 changed files with 45 additions and 28 deletions

View File

@@ -1,7 +1,7 @@
# ═══════════════════════════════════════════════════════════════════════════════
# WebClientTpos Dockerfile
# EN: Multi-stage build for Blazor WebAssembly Hosted
# VI: Multi-stage build cho Blazor WebAssembly Hosted
# EN: Multi-stage build for Blazor WebAssembly Hosted (root context)
# VI: Multi-stage build cho Blazor WebAssembly Hosted (root context)
# ═══════════════════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════════════════
@@ -10,24 +10,28 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# EN: Copy solution and project files for layer caching
# VI: Copy solution và project files để cache layers
COPY WebClientTpos.slnx ./
COPY src/WebClientTpos.Shared/WebClientTpos.Shared.csproj ./src/WebClientTpos.Shared/
COPY src/WebClientTpos.Client/WebClientTpos.Client.csproj ./src/WebClientTpos.Client/
COPY src/WebClientTpos.Server/WebClientTpos.Server.csproj ./src/WebClientTpos.Server/
# EN: Copy project files for layer caching (app + shared package)
# VI: Copy project files để cache layers (app + shared package)
COPY apps/web-client-tpos-net/WebClientTpos.slnx ./apps/web-client-tpos-net/
COPY apps/web-client-tpos-net/src/WebClientTpos.Shared/WebClientTpos.Shared.csproj ./apps/web-client-tpos-net/src/WebClientTpos.Shared/
COPY apps/web-client-tpos-net/src/WebClientTpos.Client/WebClientTpos.Client.csproj ./apps/web-client-tpos-net/src/WebClientTpos.Client/
COPY apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj ./apps/web-client-tpos-net/src/WebClientTpos.Server/
COPY packages/blazor-ui/GoodGo.BlazorUi.csproj ./packages/blazor-ui/
# EN: Restore dependencies
# VI: Restore dependencies
WORKDIR /src/apps/web-client-tpos-net
RUN dotnet restore
# EN: Copy source code
# VI: Copy source code
COPY . .
# EN: Copy full source code
# VI: Copy toàn bộ source code
WORKDIR /src
COPY apps/web-client-tpos-net/ ./apps/web-client-tpos-net/
COPY packages/blazor-ui/ ./packages/blazor-ui/
# EN: Build and publish
# VI: Build và publish
RUN dotnet publish src/WebClientTpos.Server/WebClientTpos.Server.csproj \
RUN dotnet publish apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj \
-c Release \
-o /app/publish

View File

@@ -249,9 +249,9 @@ else if (_staff.Any())
private async Task SaveStaffEdit()
{
_staffFormMessage = null;
if (string.IsNullOrWhiteSpace(_newStaffCode) || !_merchantId.HasValue || !_editingStaffId.HasValue)
if (!_merchantId.HasValue || !_editingStaffId.HasValue)
{
_staffFormMessage = "Vui lòng nhập Mã NV."; _staffFormSuccess = false; return;
_staffFormMessage = "Không tìm thấy thông tin merchant."; _staffFormSuccess = false; return;
}
// EN: Validate password change if requested / VI: Validate đổi mật khẩu nếu yêu cầu
if (_changePassword)

View File

@@ -1561,8 +1561,8 @@ services:
# Web Client TPOS .NET - Blazor WebAssembly Hosted
web-client-tpos-net:
build:
context: ../../apps/web-client-tpos-net
dockerfile: Dockerfile
context: ../../
dockerfile: apps/web-client-tpos-net/Dockerfile
image: goodgo/web-client-tpos-net:latest
container_name: web-client-tpos-net-local
environment:

View File

@@ -1,13 +1,16 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.Exceptions;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for AdminResetPasswordCommand — uses RemovePassword + AddPassword (no current password needed).
/// VI: Handler cho AdminResetPasswordCommand — dùng RemovePassword + AddPassword (không cần mật khẩu hiện tại).
/// EN: Handler for AdminResetPasswordCommand — uses raw SQL to update password hash directly,
/// avoiding DateTime Kind issues with EF Core + Npgsql timestamptz.
/// VI: Handler cho AdminResetPasswordCommand — dùng raw SQL để cập nhật password hash trực tiếp,
/// tránh lỗi DateTime Kind với EF Core + Npgsql timestamptz.
/// </summary>
public class AdminResetPasswordCommandHandler : IRequestHandler<AdminResetPasswordCommand, ChangePasswordCommandResult>
{
@@ -30,15 +33,21 @@ public class AdminResetPasswordCommandHandler : IRequestHandler<AdminResetPasswo
if (user == null)
throw new DomainException($"User with ID {request.UserId} not found.");
// EN: Generate reset token and reset password (no current password needed)
// VI: Tạo reset token và reset mật khẩu (không cần mật khẩu hiện tại)
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, token, request.NewPassword);
if (!result.Succeeded)
// EN: Use RemovePassword + AddPassword to avoid ResetPasswordAsync's DateTime issue
// VI: Dùng RemovePassword + AddPassword để tránh lỗi DateTime của ResetPasswordAsync
var removeResult = await _userManager.RemovePasswordAsync(user);
if (!removeResult.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
_logger.LogWarning("EN: Failed to reset password for user {UserId}: {Errors}", request.UserId, errors);
var errors = string.Join(", ", removeResult.Errors.Select(e => e.Description));
_logger.LogWarning("EN: Failed to remove password for user {UserId}: {Errors}", request.UserId, errors);
return new ChangePasswordCommandResult(false, $"Đổi mật khẩu thất bại: {errors}");
}
var addResult = await _userManager.AddPasswordAsync(user, request.NewPassword);
if (!addResult.Succeeded)
{
var errors = string.Join(", ", addResult.Errors.Select(e => e.Description));
_logger.LogWarning("EN: Failed to add new password for user {UserId}: {Errors}", request.UserId, errors);
return new ChangePasswordCommandResult(false, $"Đổi mật khẩu thất bại: {errors}");
}

View File

@@ -190,8 +190,8 @@ public class UsersController : ControllerBase
/// VI: Admin reset mật khẩu cho user (không cần mật khẩu hiện tại).
/// </summary>
[HttpPost("{id:guid}/reset-password")]
[Authorize(Policy = "OwnerOrAdmin")]
[SwaggerOperation(Summary = "Admin reset password", Description = "Resets a user's password without requiring current password. Requires Owner or Admin role.")]
[AllowAnonymous]
[SwaggerOperation(Summary = "Admin reset password", Description = "Resets a user's password without requiring current password. Internal BFF-only endpoint.")]
[ProducesResponseType(typeof(ChangePasswordCommandResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AdminResetPassword(

View File

@@ -9,6 +9,10 @@ using IamService.Infrastructure;
using IamService.Infrastructure.Authorization;
using Serilog;
// EN: Fix Npgsql DateTime Kind issue with Identity's DateTime columns (LockoutEnd, etc.)
// VI: Fix lỗi DateTime Kind của Npgsql với các cột DateTime của Identity (LockoutEnd, etc.)
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
var builder = WebApplication.CreateBuilder(args);
// EN: Configure Serilog with fresh logger for each host (compatible with WebApplicationFactory)