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:
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -20,7 +20,7 @@ public abstract class AdminBase : ComponentBase
|
||||
/// </summary>
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await AuthService.TryRestoreSessionAsync();
|
||||
await AuthService.TryRestoreSessionAsync("owner");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
var (ok, error) = await AuthSvc.LoginAsync(_email, _password);
|
||||
var (ok, error) = await AuthSvc.LoginAsync(_email, _password, "staff");
|
||||
|
||||
if (ok)
|
||||
{
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await AuthService.TryRestoreSessionAsync();
|
||||
await AuthService.TryRestoreSessionAsync("owner");
|
||||
if (!AuthState.IsAuthenticated)
|
||||
{
|
||||
Navigation.NavigateTo("/login");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user