feat: enhance error handling for staff and member creation, update IAM token lifetime, and refine staff query enumeration.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user