From deffb9de4a63eda36b66daca71a4656f5e58d9ac Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 13 Mar 2026 16:38:01 +0700 Subject: [PATCH] fix: resolve attendance staffName display and token conflict between staff/admin sessions 1. Attendance API now joins with MerchantStaff to return staffName instead of showing truncated staffId 2. AuthService uses role-suffixed localStorage keys (aPOS_token_owner, aPOS_token_staff) to prevent staff and admin tokens from overwriting each other on the same origin Co-Authored-By: Claude Opus 4.6 --- .../Layout/AdminLayout.razor | 5 +- .../Layout/StaffLayout.razor | 5 +- .../Pages/Admin/AdminBase.cs | 2 +- .../Pages/Auth/LoginAdmin.razor | 2 +- .../Pages/Auth/LoginBranch.razor | 2 +- .../Pages/Auth/LoginStaff.razor | 2 +- .../Pages/Auth/Profile.razor | 2 +- .../WebClientTpos.Client/Pages/Pos/PosBase.cs | 2 +- .../Pages/Staff/StaffAttendance.razor | 2 +- .../Pages/Staff/StaffDashboard.razor | 2 +- .../Pages/Staff/StaffLeave.razor | 2 +- .../Services/AuthService.cs | 69 ++++++++++++++----- .../Queries/Attendance/GetAttendanceQuery.cs | 36 ++++++++-- .../IMerchantStaffRepository.cs | 6 ++ .../Repositories/MerchantStaffRepository.cs | 9 +++ 15 files changed, 109 insertions(+), 39 deletions(-) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor index 8bef10ea..98ad5285 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor @@ -203,7 +203,7 @@ // JS interop chỉ khả dụng trong OnAfterRenderAsync, không phải OnInitializedAsync. if (!AuthState.IsAuthenticated) { - try { await AuthSvc.TryRestoreSessionAsync(); } catch { } + try { await AuthSvc.TryRestoreSessionAsync("owner"); } catch { } } // EN: Resolve user display name from localStorage fallback @@ -212,7 +212,8 @@ { try { - var email = await JS.InvokeAsync("localStorage.getItem", "aPOS_email"); + var email = await JS.InvokeAsync("localStorage.getItem", "aPOS_email_owner") + ?? await JS.InvokeAsync("localStorage.getItem", "aPOS_email"); if (!string.IsNullOrEmpty(email)) _resolvedUserName = email.Split('@').FirstOrDefault() ?? "Admin"; else if (AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail)) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor index adc1e0fd..876724aa 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/StaffLayout.razor @@ -192,7 +192,7 @@ // JS interop chỉ khả dụng trong OnAfterRenderAsync, không phải OnInitializedAsync. if (!AuthState.IsAuthenticated) { - try { await AuthSvc.TryRestoreSessionAsync(); } catch { } + try { await AuthSvc.TryRestoreSessionAsync("staff"); } catch { } } // EN: Load staff profile (role, shop) after session is restored @@ -205,7 +205,8 @@ { try { - var email = await JS.InvokeAsync("localStorage.getItem", "aPOS_email"); + var email = await JS.InvokeAsync("localStorage.getItem", "aPOS_email_staff") + ?? await JS.InvokeAsync("localStorage.getItem", "aPOS_email"); if (!string.IsNullOrEmpty(email)) _resolvedUserName = email.Split('@').FirstOrDefault() ?? "Staff"; else if (AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail)) diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminBase.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminBase.cs index 79522cb9..e41ab011 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminBase.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Admin/AdminBase.cs @@ -20,7 +20,7 @@ public abstract class AdminBase : ComponentBase /// protected override async Task OnInitializedAsync() { - await AuthService.TryRestoreSessionAsync(); + await AuthService.TryRestoreSessionAsync("owner"); } /// diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginAdmin.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginAdmin.razor index 750d411f..80eed6d6 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginAdmin.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginAdmin.razor @@ -122,7 +122,7 @@ _isLoading = true; StateHasChanged(); - var (ok, error) = await AuthSvc.LoginAsync(_email, _password); + var (ok, error) = await AuthSvc.LoginAsync(_email, _password, "owner"); if (ok) { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginBranch.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginBranch.razor index 37946e6f..737cc20d 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginBranch.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginBranch.razor @@ -101,7 +101,7 @@ _isLoading = true; StateHasChanged(); - var (ok, error) = await AuthSvc.LoginAsync(_email, _password); + var (ok, error) = await AuthSvc.LoginAsync(_email, _password, "branch"); if (ok) { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor index 53736168..85e3f2fe 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/LoginStaff.razor @@ -116,7 +116,7 @@ try { - var (ok, error) = await AuthSvc.LoginAsync(_email, _password); + var (ok, error) = await AuthSvc.LoginAsync(_email, _password, "staff"); if (ok) { diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/Profile.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/Profile.razor index 76d91db6..30fb2746 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/Profile.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Auth/Profile.razor @@ -159,7 +159,7 @@ protected override async Task OnInitializedAsync() { - await AuthService.TryRestoreSessionAsync(); + await AuthService.TryRestoreSessionAsync("owner"); if (!AuthState.IsAuthenticated) { Navigation.NavigateTo("/login"); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/PosBase.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/PosBase.cs index 5ef0897a..8a304580 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/PosBase.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Pos/PosBase.cs @@ -57,7 +57,7 @@ public abstract class PosBase : ComponentBase /// protected override async Task OnInitializedAsync() { - await AuthService.TryRestoreSessionAsync(); + await AuthService.TryRestoreSessionAsync("staff"); await base.OnInitializedAsync(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffAttendance.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffAttendance.razor index ddf4f3a3..9f0f8010 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffAttendance.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffAttendance.razor @@ -110,7 +110,7 @@ { // EN: Restore auth session before loading data — token may not be in AuthState yet after forceLoad. // VI: Khôi phục session auth trước khi tải data — token có thể chưa có trong AuthState sau forceLoad. - await AuthSvc.TryRestoreSessionAsync(); + await AuthSvc.TryRestoreSessionAsync("staff"); await LoadData(); StateHasChanged(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffDashboard.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffDashboard.razor index 2aac0b69..0284f2ad 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffDashboard.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffDashboard.razor @@ -156,7 +156,7 @@ { // EN: Restore auth session before loading data — token may not be in AuthState yet after forceLoad. // VI: Khôi phục session auth trước khi tải data — token có thể chưa có trong AuthState sau forceLoad. - await AuthSvc.TryRestoreSessionAsync(); + await AuthSvc.TryRestoreSessionAsync("staff"); await LoadDashboardData(); StateHasChanged(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffLeave.razor b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffLeave.razor index 1ddd81ce..b77d293d 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffLeave.razor +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Pages/Staff/StaffLeave.razor @@ -147,7 +147,7 @@ { // EN: Restore auth session before loading data — token may not be in AuthState yet after forceLoad. // VI: Khôi phục session auth trước khi tải data — token có thể chưa có trong AuthState sau forceLoad. - await AuthSvc.TryRestoreSessionAsync(); + await AuthSvc.TryRestoreSessionAsync("staff"); await LoadLeaveRequests(); StateHasChanged(); } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthService.cs index 6080ef00..c8b84aa0 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthService.cs @@ -20,9 +20,19 @@ public class AuthService private readonly IJSRuntime _js; private readonly AuthStateService _authState; - private const string TokenKey = "aPOS_token"; - private const string UserEmailKey = "aPOS_email"; - private const string UserNameKey = "aPOS_name"; + // EN: Role-based localStorage keys to prevent token conflict between admin/staff sessions. + // VI: Dùng key localStorage theo role để tránh xung đột token giữa admin/staff. + private const string TokenKeyPrefix = "aPOS_token"; + private const string UserEmailKeyPrefix = "aPOS_email"; + private const string UserNameKeyPrefix = "aPOS_name"; + // EN: Legacy keys for backward compatibility (read-only fallback). + // VI: Key cũ để tương thích ngược (chỉ đọc fallback). + private const string LegacyTokenKey = "aPOS_token"; + private const string LegacyEmailKey = "aPOS_email"; + + private static string TokenKey(string role) => $"{TokenKeyPrefix}_{role}"; + private static string UserEmailKey(string role) => $"{UserEmailKeyPrefix}_{role}"; + private static string UserNameKey(string role) => $"{UserNameKeyPrefix}_{role}"; // EN: Duende IdentityServer client config // VI: Cấu hình client Duende IdentityServer @@ -107,7 +117,7 @@ public class AuthService /// EN: Login via Duende IdentityServer password grant. /// VI: Đăng nhập qua Duende IdentityServer password grant. /// - public async Task<(bool Success, string? Error)> LoginAsync(string email, string password) + public async Task<(bool Success, string? Error)> LoginAsync(string email, string password, string role = "owner") { try { @@ -132,12 +142,12 @@ public class AuthService if (token != null && !string.IsNullOrEmpty(token.AccessToken)) { - // EN: Save token to localStorage + AuthStateService - // VI: Lưu token vào localStorage + AuthStateService - await _js.InvokeVoidAsync("localStorage.setItem", TokenKey, token.AccessToken); - await _js.InvokeVoidAsync("localStorage.setItem", UserEmailKey, email); + // EN: Save token with role-specific key to prevent admin/staff conflict. + // VI: Lưu token với key theo role để tránh xung đột admin/staff. + await _js.InvokeVoidAsync("localStorage.setItem", TokenKey(role), token.AccessToken); + await _js.InvokeVoidAsync("localStorage.setItem", UserEmailKey(role), email); - _authState.Login(email, token.AccessToken, "owner"); + _authState.Login(email, token.AccessToken, role); return (true, null); } @@ -161,11 +171,14 @@ public class AuthService /// EN: Get stored access token. /// VI: Lấy access token đã lưu. /// - public async Task GetTokenAsync() + public async Task GetTokenAsync(string role = "owner") { try { - return await _js.InvokeAsync("localStorage.getItem", TokenKey); + var token = await _js.InvokeAsync("localStorage.getItem", TokenKey(role)); + if (string.IsNullOrEmpty(token)) + token = await _js.InvokeAsync("localStorage.getItem", LegacyTokenKey); + return token; } catch { @@ -177,16 +190,30 @@ public class AuthService /// EN: Try to restore session from localStorage on app start. /// VI: Khôi phục session từ localStorage khi app khởi động. /// - public async Task TryRestoreSessionAsync() + /// + /// EN: Try to restore session from localStorage. Uses role-specific keys, falls back to legacy keys. + /// VI: Khôi phục session từ localStorage. Dùng key theo role, fallback sang key cũ. + /// + public async Task TryRestoreSessionAsync(string role = "owner") { try { - var token = await _js.InvokeAsync("localStorage.getItem", TokenKey); - var email = await _js.InvokeAsync("localStorage.getItem", UserEmailKey); + // EN: Try role-specific keys first, then fall back to legacy keys. + // VI: Thử key theo role trước, rồi fallback sang key cũ. + var token = await _js.InvokeAsync("localStorage.getItem", TokenKey(role)); + var email = await _js.InvokeAsync("localStorage.getItem", UserEmailKey(role)); + + if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(email)) + { + // EN: Fallback to legacy keys for backward compatibility. + // VI: Fallback sang key cũ để tương thích ngược. + token = await _js.InvokeAsync("localStorage.getItem", LegacyTokenKey); + email = await _js.InvokeAsync("localStorage.getItem", LegacyEmailKey); + } if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(email)) { - _authState.Login(email, token, "owner"); + _authState.Login(email, token, role); } } catch { /* localStorage not available */ } @@ -196,13 +223,17 @@ public class AuthService /// EN: Logout and clear stored data. /// VI: Đăng xuất và xóa dữ liệu đã lưu. /// - public async Task LogoutAsync() + public async Task LogoutAsync(string role = "owner") { try { - await _js.InvokeVoidAsync("localStorage.removeItem", TokenKey); - await _js.InvokeVoidAsync("localStorage.removeItem", UserEmailKey); - await _js.InvokeVoidAsync("localStorage.removeItem", UserNameKey); + await _js.InvokeVoidAsync("localStorage.removeItem", TokenKey(role)); + await _js.InvokeVoidAsync("localStorage.removeItem", UserEmailKey(role)); + await _js.InvokeVoidAsync("localStorage.removeItem", UserNameKey(role)); + // EN: Also clear legacy keys on logout. + // VI: Cũng xóa key cũ khi logout. + await _js.InvokeVoidAsync("localStorage.removeItem", LegacyTokenKey); + await _js.InvokeVoidAsync("localStorage.removeItem", LegacyEmailKey); } catch { } diff --git a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Attendance/GetAttendanceQuery.cs b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Attendance/GetAttendanceQuery.cs index 6bc752ea..c748c9f8 100644 --- a/services/merchant-service-net/src/MerchantService.API/Application/Queries/Attendance/GetAttendanceQuery.cs +++ b/services/merchant-service-net/src/MerchantService.API/Application/Queries/Attendance/GetAttendanceQuery.cs @@ -1,15 +1,20 @@ -// EN: Query to get attendance records for a staff member. -// VI: Query lay ban ghi cham cong cho nhan vien. +// EN: Query to get attendance records for a staff member or shop. +// VI: Query lấy bản ghi chấm công cho nhân viên hoặc cửa hàng. using MediatR; using MerchantService.Domain.AggregatesModel.AttendanceAggregate; +using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; namespace MerchantService.API.Application.Queries.Attendance; public record GetAttendanceByStaffQuery(Guid StaffId, int Month, int Year) : IRequest>; public record GetAttendanceByShopQuery(Guid ShopId, int Month, int Year) : IRequest>; -public record AttendanceDto(Guid Id, Guid StaffId, DateTime Date, DateTime? CheckIn, DateTime? CheckOut, +/// +/// EN: Attendance DTO with optional StaffName for admin views. +/// VI: DTO chấm công có StaffName tùy chọn cho view admin. +/// +public record AttendanceDto(Guid Id, Guid StaffId, string? StaffName, DateTime Date, DateTime? CheckIn, DateTime? CheckOut, decimal? HoursWorked, string Status, string? Notes); public class GetAttendanceByStaffQueryHandler : IRequestHandler> @@ -21,21 +26,38 @@ public class GetAttendanceByStaffQueryHandler : IRequestHandler> Handle(GetAttendanceByStaffQuery request, CancellationToken ct) { var records = await _repo.GetByStaffAndMonthAsync(request.StaffId, request.Month, request.Year, ct); - return records.Select(r => new AttendanceDto(r.Id, r.StaffId, r.Date, r.CheckIn, r.CheckOut, + return records.Select(r => new AttendanceDto(r.Id, r.StaffId, null, r.Date, r.CheckIn, r.CheckOut, r.HoursWorked, r.Status, r.Notes)).ToList(); } } +/// +/// EN: Handler for shop attendance — joins with MerchantStaff to include staff names. +/// VI: Handler chấm công cửa hàng — join MerchantStaff để lấy tên nhân viên. +/// public class GetAttendanceByShopQueryHandler : IRequestHandler> { private readonly IAttendanceRepository _repo; + private readonly IMerchantStaffRepository _staffRepo; - public GetAttendanceByShopQueryHandler(IAttendanceRepository repo) => _repo = repo; + public GetAttendanceByShopQueryHandler(IAttendanceRepository repo, IMerchantStaffRepository staffRepo) + { + _repo = repo; + _staffRepo = staffRepo; + } public async Task> Handle(GetAttendanceByShopQuery request, CancellationToken ct) { var records = await _repo.GetByShopAndMonthAsync(request.ShopId, request.Month, request.Year, ct); - return records.Select(r => new AttendanceDto(r.Id, r.StaffId, r.Date, r.CheckIn, r.CheckOut, - r.HoursWorked, r.Status, r.Notes)).ToList(); + + // EN: Load staff names for all unique staffIds in this batch. + // VI: Tải tên nhân viên cho tất cả staffId duy nhất trong batch này. + var staffIds = records.Select(r => r.StaffId).Distinct().ToList(); + var staffList = await _staffRepo.GetByIdsAsync(staffIds, ct); + var staffNames = staffList.ToDictionary(s => s.Id, s => $"{s.FirstName} {s.LastName}".Trim()); + + return records.Select(r => new AttendanceDto(r.Id, r.StaffId, + staffNames.TryGetValue(r.StaffId, out var name) ? name : null, + r.Date, r.CheckIn, r.CheckOut, r.HoursWorked, r.Status, r.Notes)).ToList(); } } diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs index c89be1bf..fc677e46 100644 --- a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs @@ -53,6 +53,12 @@ public interface IMerchantStaffRepository : IRepository /// Task GetByEmailAsync(string email, CancellationToken cancellationToken = default); + /// + /// EN: Get multiple staff members by their IDs (batch lookup). + /// VI: Lấy nhiều nhân viên theo danh sách ID (batch lookup). + /// + Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default); + /// /// EN: Add a new staff member. /// VI: Thêm nhân viên mới. diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs index c4e1170f..1fa35aa0 100644 --- a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs @@ -81,6 +81,15 @@ public class MerchantStaffRepository : IMerchantStaffRepository .FirstOrDefaultAsync(s => s.Email == email && s.StatusId != StaffStatus.Terminated.Id, cancellationToken); } + /// + public async Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default) + { + var idList = ids.ToList(); + return await _context.MerchantStaff + .Where(s => idList.Contains(s.Id)) + .ToListAsync(cancellationToken); + } + /// public MerchantStaff Add(MerchantStaff staff) {