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 <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-03-13 16:38:01 +07:00
parent ddd02be26e
commit deffb9de4a
15 changed files with 109 additions and 39 deletions

View File

@@ -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<string?>("localStorage.getItem", "aPOS_email");
var email = await JS.InvokeAsync<string?>("localStorage.getItem", "aPOS_email_owner")
?? await JS.InvokeAsync<string?>("localStorage.getItem", "aPOS_email");
if (!string.IsNullOrEmpty(email))
_resolvedUserName = email.Split('@').FirstOrDefault() ?? "Admin";
else if (AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail))

View File

@@ -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<string?>("localStorage.getItem", "aPOS_email");
var email = await JS.InvokeAsync<string?>("localStorage.getItem", "aPOS_email_staff")
?? await JS.InvokeAsync<string?>("localStorage.getItem", "aPOS_email");
if (!string.IsNullOrEmpty(email))
_resolvedUserName = email.Split('@').FirstOrDefault() ?? "Staff";
else if (AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail))

View File

@@ -20,7 +20,7 @@ public abstract class AdminBase : ComponentBase
/// </summary>
protected override async Task OnInitializedAsync()
{
await AuthService.TryRestoreSessionAsync();
await AuthService.TryRestoreSessionAsync("owner");
}
/// <summary>

View File

@@ -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)
{

View File

@@ -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)
{

View File

@@ -116,7 +116,7 @@
try
{
var (ok, error) = await AuthSvc.LoginAsync(_email, _password);
var (ok, error) = await AuthSvc.LoginAsync(_email, _password, "staff");
if (ok)
{

View File

@@ -159,7 +159,7 @@
protected override async Task OnInitializedAsync()
{
await AuthService.TryRestoreSessionAsync();
await AuthService.TryRestoreSessionAsync("owner");
if (!AuthState.IsAuthenticated)
{
Navigation.NavigateTo("/login");

View File

@@ -57,7 +57,7 @@ public abstract class PosBase : ComponentBase
/// </summary>
protected override async Task OnInitializedAsync()
{
await AuthService.TryRestoreSessionAsync();
await AuthService.TryRestoreSessionAsync("staff");
await base.OnInitializedAsync();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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.
/// </summary>
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.
/// </summary>
public async Task<string?> GetTokenAsync()
public async Task<string?> GetTokenAsync(string role = "owner")
{
try
{
return await _js.InvokeAsync<string?>("localStorage.getItem", TokenKey);
var token = await _js.InvokeAsync<string?>("localStorage.getItem", TokenKey(role));
if (string.IsNullOrEmpty(token))
token = await _js.InvokeAsync<string?>("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.
/// </summary>
public async Task TryRestoreSessionAsync()
/// <summary>
/// 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ũ.
/// </summary>
public async Task TryRestoreSessionAsync(string role = "owner")
{
try
{
var token = await _js.InvokeAsync<string?>("localStorage.getItem", TokenKey);
var email = await _js.InvokeAsync<string?>("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<string?>("localStorage.getItem", TokenKey(role));
var email = await _js.InvokeAsync<string?>("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<string?>("localStorage.getItem", LegacyTokenKey);
email = await _js.InvokeAsync<string?>("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.
/// </summary>
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 { }

View File

@@ -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 ly bn ghi chm 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<List<AttendanceDto>>;
public record GetAttendanceByShopQuery(Guid ShopId, int Month, int Year) : IRequest<List<AttendanceDto>>;
public record AttendanceDto(Guid Id, Guid StaffId, DateTime Date, DateTime? CheckIn, DateTime? CheckOut,
/// <summary>
/// EN: Attendance DTO with optional StaffName for admin views.
/// VI: DTO chấm công có StaffName tùy chọn cho view admin.
/// </summary>
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<GetAttendanceByStaffQuery, List<AttendanceDto>>
@@ -21,21 +26,38 @@ public class GetAttendanceByStaffQueryHandler : IRequestHandler<GetAttendanceByS
public async Task<List<AttendanceDto>> 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();
}
}
/// <summary>
/// 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.
/// </summary>
public class GetAttendanceByShopQueryHandler : IRequestHandler<GetAttendanceByShopQuery, List<AttendanceDto>>
{
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<List<AttendanceDto>> 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();
}
}

View File

@@ -53,6 +53,12 @@ public interface IMerchantStaffRepository : IRepository<MerchantStaff>
/// </summary>
Task<MerchantStaff?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
/// <summary>
/// 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).
/// </summary>
Task<IReadOnlyList<MerchantStaff>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add a new staff member.
/// VI: Thêm nhân viên mới.

View File

@@ -81,6 +81,15 @@ public class MerchantStaffRepository : IMerchantStaffRepository
.FirstOrDefaultAsync(s => s.Email == email && s.StatusId != StaffStatus.Terminated.Id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantStaff>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
{
var idList = ids.ToList();
return await _context.MerchantStaff
.Where(s => idList.Contains(s.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public MerchantStaff Add(MerchantStaff staff)
{