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>
243 lines
9.7 KiB
C#
243 lines
9.7 KiB
C#
// EN: Authentication service — handles register, login, token management.
|
|
// VI: Service xác thực — xử lý đăng ký, đăng nhập, quản lý token.
|
|
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.JSInterop;
|
|
using WebClientTpos.Shared.DTOs;
|
|
using WebClientTpos.Shared;
|
|
|
|
namespace WebClientTpos.Client.Services;
|
|
|
|
/// <summary>
|
|
/// EN: Authentication service for Duende IdentityServer integration.
|
|
/// VI: Service xác thực tích hợp Duende IdentityServer.
|
|
/// </summary>
|
|
public class AuthService
|
|
{
|
|
private readonly HttpClient _http;
|
|
private readonly IJSRuntime _js;
|
|
private readonly AuthStateService _authState;
|
|
|
|
// 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
|
|
private const string ClientId = "password-client";
|
|
private const string ClientSecret = "password-client-secret";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public AuthService(HttpClient http, IJSRuntime js, AuthStateService authState)
|
|
{
|
|
_http = http;
|
|
_js = js;
|
|
_authState = authState;
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Register a new user account.
|
|
/// VI: Đăng ký tài khoản mới.
|
|
/// </summary>
|
|
public async Task<(bool Success, string? Error)> RegisterAsync(RegisterDto dto)
|
|
{
|
|
try
|
|
{
|
|
// EN: Build payload with FirstName/LastName from form (IAM requires these)
|
|
// VI: Tạo payload với FirstName/LastName từ form (IAM yêu cầu)
|
|
var payload = new
|
|
{
|
|
dto.Email,
|
|
dto.Password,
|
|
dto.FirstName,
|
|
dto.LastName
|
|
};
|
|
|
|
// EN: Use PascalCase serialization to match backend RegisterUserCommand record params
|
|
// VI: Dùng PascalCase serialization để khớp với record params backend
|
|
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = null };
|
|
var jsonContent = JsonContent.Create(payload, options: jsonOptions);
|
|
|
|
var response = await _http.PostAsync("/api/auth/register", jsonContent);
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
return (true, null);
|
|
}
|
|
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
try
|
|
{
|
|
// EN: Try to parse structured validation errors from IAM
|
|
// VI: Parse lỗi validation có cấu trúc từ IAM
|
|
using var doc = JsonDocument.Parse(content);
|
|
if (doc.RootElement.TryGetProperty("errors", out var errors))
|
|
{
|
|
var msgs = new List<string>();
|
|
foreach (var prop in errors.EnumerateObject())
|
|
{
|
|
foreach (var err in prop.Value.EnumerateArray())
|
|
msgs.Add(err.GetString() ?? prop.Name);
|
|
}
|
|
return (false, string.Join("; ", msgs));
|
|
}
|
|
if (doc.RootElement.TryGetProperty("title", out var title))
|
|
return (false, title.GetString());
|
|
|
|
return (false, content.Length > 200 ? "Đăng ký thất bại" : content);
|
|
}
|
|
catch
|
|
{
|
|
return (false, "Đăng ký thất bại");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return (false, $"Lỗi ({ex.GetType().Name})");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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, string role = "owner")
|
|
{
|
|
try
|
|
{
|
|
// EN: Build form-urlencoded body for token endpoint
|
|
// VI: Tạo body form-urlencoded cho token endpoint
|
|
var formData = new FormUrlEncodedContent(new[]
|
|
{
|
|
new KeyValuePair<string, string>("grant_type", "password"),
|
|
new KeyValuePair<string, string>("client_id", ClientId),
|
|
new KeyValuePair<string, string>("client_secret", ClientSecret),
|
|
new KeyValuePair<string, string>("username", email),
|
|
new KeyValuePair<string, string>("password", password),
|
|
new KeyValuePair<string, string>("scope", "openid profile email api"),
|
|
});
|
|
|
|
var response = await _http.PostAsync("/api/iam/connect/token", formData);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var token = JsonSerializer.Deserialize<TokenResponse>(json, JsonOptions);
|
|
|
|
if (token != null && !string.IsNullOrEmpty(token.AccessToken))
|
|
{
|
|
// 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, role);
|
|
return (true, null);
|
|
}
|
|
|
|
return (false, "Token không hợp lệ");
|
|
}
|
|
|
|
var errorContent = await response.Content.ReadAsStringAsync();
|
|
if (errorContent.Contains("invalid_grant"))
|
|
{
|
|
return (false, "Email hoặc mật khẩu không đúng");
|
|
}
|
|
return (false, $"Đăng nhập thất bại ({response.StatusCode})");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return (false, $"Lỗi kết nối: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Get stored access token.
|
|
/// VI: Lấy access token đã lưu.
|
|
/// </summary>
|
|
public async Task<string?> GetTokenAsync(string role = "owner")
|
|
{
|
|
try
|
|
{
|
|
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
|
|
{
|
|
return _authState.Token;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Try to restore session from localStorage on app start.
|
|
/// VI: Khôi phục session từ localStorage khi app khởi động.
|
|
/// </summary>
|
|
/// <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
|
|
{
|
|
// 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, role);
|
|
}
|
|
}
|
|
catch { /* localStorage not available */ }
|
|
}
|
|
|
|
/// <summary>
|
|
/// EN: Logout and clear stored data.
|
|
/// VI: Đăng xuất và xóa dữ liệu đã lưu.
|
|
/// </summary>
|
|
public async Task LogoutAsync(string role = "owner")
|
|
{
|
|
try
|
|
{
|
|
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 { }
|
|
|
|
_authState.Logout();
|
|
}
|
|
}
|