feat: enhance error handling for staff and member creation, update IAM token lifetime, and refine staff query enumeration.

This commit is contained in:
Ho Ngoc Hai
2026-03-05 02:10:52 +07:00
parent 629fed8a55
commit 4d6c9c6ba3
7 changed files with 88 additions and 57 deletions

View File

@@ -868,9 +868,13 @@
</div>
<div><label style="font-size:12px;font-weight:600;display:block;margin-bottom:4px;">Mã quốc gia</label><input type="text" @bind="_newMemberCountry" maxlength="2" placeholder="VN" style="width:100%;padding:8px 12px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);font-size:13px;color:var(--admin-text-primary);" /></div>
</div>
@if (_memberFormMessage != null)
{
<div style="margin-top:8px;padding:8px 12px;border-radius:8px;background:@(_memberFormSuccess ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)");color:@(_memberFormSuccess ? "#22C55E" : "#EF4444");font-size:13px;">@_memberFormMessage</div>
}
<div style="display:flex;gap:8px;margin-top:12px;">
<button class="admin-btn-primary" style="font-size:12px;padding:6px 16px;" @onclick="SaveMember">Lưu</button>
<button style="padding:6px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;" @onclick='() => { _showMemberForm = false; }'>Hủy</button>
<button style="padding:6px 16px;border-radius:8px;border:1px solid var(--admin-border-subtle);background:var(--admin-bg-elevated);color:var(--admin-text-primary);font-size:12px;cursor:pointer;" @onclick='() => { _showMemberForm = false; _memberFormMessage = null; }'>Hủy</button>
</div>
</div>
</div>
@@ -2866,9 +2870,9 @@
{
_staffFormMessage = "Vui lòng nhập đầy đủ email, mật khẩu, họ và tên."; _staffFormSuccess = false; return;
}
var ok = await DataService.InviteStaffWithAccountAsync(new PosDataService.InviteStaffWithAccountRequest(
var (ok, err) = await DataService.InviteStaffWithAccountAsync(new PosDataService.InviteStaffWithAccountRequest(
_newStaffEmail, _newStaffPassword, _newStaffFirstName, _newStaffLastName, _newStaffRole, _shopGuid));
if (!ok) { _staffFormMessage = "Lỗi tạo tài khoản IAM. Kiểm tra email/mật khẩu."; _staffFormSuccess = false; return; }
if (!ok) { _staffFormMessage = err ?? "Lỗi tạo tài khoản IAM. Kiểm tra email/mật khẩu."; _staffFormSuccess = false; return; }
_staffFormMessage = $"Đã tạo tài khoản + mời NV '{_newStaffEmail}' thành công!"; _staffFormSuccess = true;
}
else
@@ -3042,16 +3046,24 @@
}
// ═══ MEMBER CRUD ═══
private string? _memberFormMessage;
private bool _memberFormSuccess;
private async Task SaveMember()
{
bool ok;
_memberFormMessage = null;
if (_editingMemberId.HasValue)
ok = await DataService.UpdateMemberAsync(_editingMemberId.Value, new PosDataService.UpdateMemberRequest(_newMemberGender, null));
{
var ok = await DataService.UpdateMemberAsync(_editingMemberId.Value, new PosDataService.UpdateMemberRequest(_newMemberGender, null));
if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _members = await DataService.GetMembersAsync(); }
}
else
ok = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry,
{
var (ok, err) = await DataService.CreateMemberAsync(new PosDataService.CreateMemberRequest(_newMemberGender, _newMemberCountry,
string.IsNullOrWhiteSpace(_newMemberName) ? null : _newMemberName,
string.IsNullOrWhiteSpace(_newMemberPhone) ? null : _newMemberPhone));
if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _members = await DataService.GetMembersAsync(); }
if (ok) { _showMemberForm = false; _editingMemberId = null; _newMemberName = ""; _newMemberPhone = ""; _memberFormMessage = null; _members = await DataService.GetMembersAsync(); }
else { _memberFormMessage = err ?? "Lỗi tạo khách hàng."; _memberFormSuccess = false; }
}
}
private void EditMember(PosDataService.MemberInfo m)

View File

@@ -46,6 +46,25 @@ public class PosDataService
}
}
private static async Task<string> TryExtractError(HttpResponseMessage resp)
{
if ((int)resp.StatusCode == 401)
return "Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.";
try
{
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("message", out var msg))
return msg.GetString() ?? resp.StatusCode.ToString();
if (doc.RootElement.TryGetProperty("detail", out var detail))
return detail.GetString() ?? resp.StatusCode.ToString();
if (doc.RootElement.TryGetProperty("title", out var title))
return title.GetString() ?? resp.StatusCode.ToString();
return body.Length > 200 ? $"Lỗi ({resp.StatusCode})" : body;
}
catch { return $"Lỗi ({resp.StatusCode})"; }
}
/// <summary>
/// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes.
/// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope.
@@ -79,6 +98,13 @@ public class PosDataService
return JsonSerializer.Deserialize<List<T>>(data.GetRawText(), _jsonOptions) ?? new();
}
// Case 5: { "<anyKey>": [...] } — generic single-array-property wrapper (e.g. { "members": [...] })
foreach (var prop in root.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.Array)
return JsonSerializer.Deserialize<List<T>>(prop.Value.GetRawText(), _jsonOptions) ?? new();
}
return new();
}
@@ -338,11 +364,13 @@ public class PosDataService
public record CreateMemberRequest(string? Gender, string? CountryCode, string? Name = null, string? Phone = null);
public record UpdateMemberRequest(string? Gender, string? Preferences);
public async Task<bool> CreateMemberAsync(CreateMemberRequest req)
public async Task<(bool Ok, string? Error)> CreateMemberAsync(CreateMemberRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/members", req, _writeOptions);
return resp.IsSuccessStatusCode;
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
return (false, err);
}
public async Task<bool> UpdateMemberAsync(Guid memberId, UpdateMemberRequest req)
@@ -723,10 +751,12 @@ public class PosDataService
public record InviteStaffWithAccountRequest(string Email, string Password, string FirstName, string LastName, string Role, Guid? ShopId);
public async Task<bool> InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req)
public async Task<(bool Ok, string? Error)> InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff/invite-with-account", req, _writeOptions);
return resp.IsSuccessStatusCode;
if (resp.IsSuccessStatusCode) return (true, null);
var err = await TryExtractError(resp);
return (false, err);
}
}

View File

@@ -46,47 +46,16 @@ public class MembershipController : ControllerBase
[HttpPost("members")]
public async Task<IActionResult> CreateMember([FromBody] JsonElement body)
{
// EN: Extract userId from JWT sub claim and inject into request body
// VI: Trích userId từ JWT sub claim và thêm vào request body
// EN: Generate a unique userId for each new customer (not tied to the admin user).
// VI: Tạo userId duy nhất cho mỗi khách hàng mới (không gắn với admin user).
var rawJson = body.GetRawText();
var userId = ExtractSubFromJwt();
if (!string.IsNullOrEmpty(userId) && !rawJson.Contains("\"userId\""))
if (!rawJson.Contains("\"userId\""))
{
// EN: Insert userId field into JSON body
// VI: Chèn trường userId vào JSON body
rawJson = rawJson.TrimEnd('}') + $",\"userId\":\"{userId}\"}}";
var newUserId = Guid.NewGuid();
rawJson = rawJson.TrimEnd('}') + $",\"userId\":\"{newUserId}\"}}";
}
var httpContent = new StringContent(rawJson, System.Text.Encoding.UTF8, "application/json");
var resp = await _membership.PostAsync("/api/v1/members", httpContent);
var respContent = await resp.Content.ReadAsStringAsync();
return new ContentResult
{
StatusCode = (int)resp.StatusCode,
Content = respContent,
ContentType = resp.Content.Headers.ContentType?.ToString() ?? "application/json"
};
}
private string? ExtractSubFromJwt()
{
var authHeader = HttpContext.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) return null;
try
{
var parts = authHeader["Bearer ".Length..].Split('.');
if (parts.Length != 3) return null;
var payload = parts[1];
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
payload = payload.Replace('-', '+').Replace('_', '/');
var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
using var doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() : null;
}
catch { return null; }
return await _membership.PostAsync("/api/v1/members", httpContent).ProxyAsync();
}
/// <summary>

View File

@@ -57,9 +57,27 @@ public class StaffController : ControllerBase
var iamResponse = await _iam.PostAsJsonAsync("/api/v1/auth/register", iamPayload);
if (!iamResponse.IsSuccessStatusCode)
{
var err = await iamResponse.Content.ReadAsStringAsync();
var errBody = await iamResponse.Content.ReadAsStringAsync();
// EN: Extract user-friendly validation messages from ProblemDetails JSON
// VI: Trích thông báo lỗi dễ hiểu từ ProblemDetails JSON
var friendlyMessage = "Lỗi tạo tài khoản IAM.";
try
{
using var doc = JsonDocument.Parse(errBody);
if (doc.RootElement.TryGetProperty("errors", out var errors))
{
var msgs = new List<string>();
foreach (var prop in errors.EnumerateObject())
foreach (var val in prop.Value.EnumerateArray())
msgs.Add(val.GetString() ?? prop.Name);
if (msgs.Count > 0) friendlyMessage = string.Join(". ", msgs) + ".";
}
else if (doc.RootElement.TryGetProperty("detail", out var detail))
friendlyMessage = detail.GetString() ?? friendlyMessage;
}
catch { /* use default message */ }
return StatusCode((int)iamResponse.StatusCode,
new { success = false, message = "IAM account creation failed", details = err });
new { success = false, message = friendlyMessage });
}
// Step 2: Invite staff via MerchantService

View File

@@ -159,9 +159,9 @@ public static class Config
ClientId = "password-client",
ClientName = "Password Grant Client",
ClientSecrets = { new Secret("password-client-secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
@@ -171,9 +171,9 @@ public static class Config
"roles",
"api"
},
AllowOfflineAccess = true,
AccessTokenLifetime = 900,
AccessTokenLifetime = 28800, // 8 hours — long-lived for admin sessions
RefreshTokenExpiration = TokenExpiration.Sliding,
SlidingRefreshTokenLifetime = 604800
},

View File

@@ -5,6 +5,7 @@ using MediatR;
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
using MerchantService.Domain.Exceptions;
using MerchantService.Domain.SeedWork;
namespace MerchantService.API.Application.Queries.Staff;
@@ -50,8 +51,8 @@ public class GetMyStaffQueryHandler : IRequestHandler<GetMyStaffQuery, IReadOnly
Email = s.Email ?? string.Empty,
EmployeeCode = s.EmployeeCode,
Phone = s.Phone,
Role = s.Role.Name,
Status = s.Status.Name,
Role = Enumeration.FromValue<StaffRole>(s.RoleId).Name,
Status = Enumeration.FromValue<StaffStatus>(s.StatusId).Name,
Permissions = (int)s.Permissions,
HasPinCode = !string.IsNullOrEmpty(s.PinCodeHash),
CreatedAt = s.CreatedAt,
@@ -59,7 +60,7 @@ public class GetMyStaffQueryHandler : IRequestHandler<GetMyStaffQuery, IReadOnly
ShopAssignments = s.ShopAssignments.Select(sm => new ShopAssignmentDto
{
ShopId = sm.ShopId,
ShopRole = sm.Role.Name,
ShopRole = Enumeration.FromValue<ShopRole>(sm.RoleId).Name,
BranchId = sm.BranchId
}).ToList()
}).ToList();

View File

@@ -51,6 +51,7 @@ public class MerchantStaffRepository : IMerchantStaffRepository
public async Task<IReadOnlyList<MerchantStaff>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default)
{
return await _context.MerchantStaff
.Include(s => s.ShopAssignments)
.Where(s => s.MerchantId == merchantId && s.StatusId != StaffStatus.Terminated.Id)
.OrderByDescending(s => s.JoinedAt)
.ToListAsync(cancellationToken);