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 98ad5285..4f0f18a2 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 @@ -11,6 +11,7 @@ @inject IJSRuntime JS @inject WebClientTpos.Client.Services.AuthStateService AuthState @inject WebClientTpos.Client.Services.AuthService AuthSvc +@inject WebClientTpos.Client.Services.PosDataService PosData @inject Microsoft.Extensions.Localization.IStringLocalizer L @using WebClientTpos.Client.Services @@ -197,29 +198,58 @@ if (firstRender) { - // EN: Restore session from localStorage if AuthState lost after forceLoad navigation. - // JS interop is only available in OnAfterRenderAsync, not OnInitializedAsync. - // VI: Khôi phục session từ localStorage nếu AuthState mất sau forceLoad navigation. - // JS interop chỉ khả dụng trong OnAfterRenderAsync, không phải OnInitializedAsync. + // EN: Restore session from BFF if AuthState is lost after forceLoad navigation. + // TryRestoreSessionAsync calls /api/bff/auth/session — reads the httpOnly cookie server-side. + // VI: Khôi phục session từ BFF nếu AuthState mất sau forceLoad navigation. + // TryRestoreSessionAsync gọi /api/bff/auth/session — đọc httpOnly cookie phía server. if (!AuthState.IsAuthenticated) { - try { await AuthSvc.TryRestoreSessionAsync("owner"); } catch { } + try { await AuthSvc.TryRestoreSessionAsync(); } catch { } } - // EN: Resolve user display name from localStorage fallback - // VI: Lấy tên hiển thị từ localStorage nếu cần - if (_resolvedUserName == null) + // EN: FRONT-C-04 — Route guard: redirect unauthenticated users to login. + // Checked after session restore so valid sessions are not wrongly rejected. + // VI: FRONT-C-04 — Bảo vệ route: chuyển hướng user chưa xác thực về trang đăng nhập. + // Kiểm tra sau khi khôi phục session để tránh từ chối sai các session hợp lệ. + if (!AuthState.IsAuthenticated) + { + var returnUrl = Uri.EscapeDataString(NavigationManager.Uri); + NavigationManager.NavigateTo($"/auth/login?returnUrl={returnUrl}", forceLoad: false); + return; + } + + // EN: FRONT-C-05 — Validate shopId against user permissions via BFF. + // If the shop is not accessible, redirect to admin index to prevent IDOR. + // VI: FRONT-C-05 — Xác thực shopId với quyền user qua BFF. + // Nếu shop không có quyền truy cập, chuyển hướng về admin để ngăn IDOR. + if (_isShopContext && !string.IsNullOrEmpty(_shopId) && Guid.TryParse(_shopId, out var shopGuid)) { try { - 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)) - _resolvedUserName = AuthState.UserEmail.Split('@').FirstOrDefault() ?? "Admin"; + var shop = await PosData.GetShopByIdAsync(shopGuid); + if (shop == null) + { + // EN: BFF returned 404/403 — user has no access to this shop. + // VI: BFF trả về 404/403 — user không có quyền truy cập shop này. + NavigationManager.NavigateTo("/admin", forceLoad: false); + return; + } + + // EN: Populate shop context from verified backend data (not just URL). + // VI: Điền thông tin context shop từ dữ liệu backend đã xác thực (không chỉ từ URL). + if (string.IsNullOrEmpty(_shopName) || _shopName.StartsWith("Shop #")) + _shopName = shop.Name; + if (_shopCategory == null && !string.IsNullOrEmpty(shop.Category)) + _shopCategory = shop.Category.ToLowerInvariant(); } - catch { /* JS interop not ready */ } + catch { /* Network error — allow render, BFF will 401 on subsequent calls */ } + } + + // EN: Resolve user display name from AuthState (restored from BFF session). + // VI: Lấy tên hiển thị từ AuthState (khôi phục từ BFF session). + if (_resolvedUserName == null && AuthState.IsAuthenticated && !string.IsNullOrEmpty(AuthState.UserEmail)) + { + _resolvedUserName = AuthState.UserEmail.Split('@').FirstOrDefault() ?? "Admin"; } 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 c8b84aa0..3518548e 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 @@ -1,9 +1,10 @@ -// 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. +// EN: Authentication service — delegates all token exchange to the BFF server. +// WASM never handles raw access tokens; they are stored in httpOnly cookies by the BFF. +// VI: Service xác thực — ủy thác trao đổi token cho BFF server. +// WASM không bao giờ xử lý access token thô; chúng được BFF lưu trong httpOnly cookie. using System.Net.Http.Json; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.JSInterop; using WebClientTpos.Shared.DTOs; using WebClientTpos.Shared; @@ -11,8 +12,10 @@ using WebClientTpos.Shared; namespace WebClientTpos.Client.Services; /// -/// EN: Authentication service for Duende IdentityServer integration. -/// VI: Service xác thực tích hợp Duende IdentityServer. +/// EN: Authentication service — proxies all auth operations through the BFF server. +/// No OAuth2 credentials or raw tokens are present in the WASM client. +/// VI: Service xác thực — proxy tất cả thao tác auth qua BFF server. +/// Không có OAuth2 credentials hay token thô trong WASM client. /// public class AuthService { @@ -20,28 +23,15 @@ public class AuthService 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"; + // EN: localStorage keys for non-sensitive session metadata (email/name for UI display only). + // The access token is NEVER stored in localStorage — it lives in an httpOnly BFF cookie. + // VI: Các key localStorage cho metadata session không nhạy cảm (email/name chỉ để hiển thị UI). + // Access token KHÔNG BAO GIỜ được lưu trong localStorage — nó ở trong httpOnly BFF cookie. + private const string StoredEmailKey = "aPOS_email"; + private const string StoredRoleKey = "aPOS_role"; private static readonly JsonSerializerOptions JsonOptions = new() { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true }; @@ -53,59 +43,40 @@ public class AuthService } /// - /// EN: Register a new user account. - /// VI: Đăng ký tài khoản mới. + /// EN: Register a new user account via BFF → IAM service. + /// VI: Đăng ký tài khoản mới qua BFF → IAM service. /// 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 + // EN: PascalCase serialization to match IAM RegisterUserCommand record params. + // VI: Serialization PascalCase để khớp với record params IAM RegisterUserCommand. + var payload = new { dto.Email, dto.Password, dto.FirstName, dto.LastName }; 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(); 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 { return (false, "Đăng ký thất bại"); } } catch (Exception ex) { @@ -114,51 +85,59 @@ public class AuthService } /// - /// EN: Login via Duende IdentityServer password grant. - /// VI: Đăng nhập qua Duende IdentityServer password grant. + /// EN: Login via BFF — sends email/password to BFF; BFF exchanges with IS4 using server-side + /// credentials and sets an httpOnly session cookie. WASM never sees the access token. + /// VI: Đăng nhập qua BFF — gửi email/password đến BFF; BFF trao đổi với IS4 dùng + /// server-side credentials và đặt httpOnly session cookie. WASM không thấy access token. /// 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("grant_type", "password"), - new KeyValuePair("client_id", ClientId), - new KeyValuePair("client_secret", ClientSecret), - new KeyValuePair("username", email), - new KeyValuePair("password", password), - new KeyValuePair("scope", "openid profile email api"), - }); - - var response = await _http.PostAsync("/api/iam/connect/token", formData); + var response = await _http.PostAsJsonAsync("/api/bff/auth/login", new { email, password }, JsonOptions); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(); - var token = JsonSerializer.Deserialize(json, JsonOptions); + using var doc = JsonDocument.Parse(json); - if (token != null && !string.IsNullOrEmpty(token.AccessToken)) + // EN: BFF returns session info (email, role, expiresAt) — not the raw token. + // VI: BFF trả về thông tin session (email, role, expiresAt) — không phải token thô. + DateTime? expiresAt = null; + string? resolvedRole = role; + + if (doc.RootElement.TryGetProperty("data", out var data)) { - // 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); + if (data.TryGetProperty("expiresAt", out var expEl) && expEl.ValueKind != JsonValueKind.Null) + expiresAt = expEl.GetDateTime(); + if (data.TryGetProperty("role", out var roleEl) && roleEl.ValueKind == JsonValueKind.String) + resolvedRole = roleEl.GetString() ?? role; + if (data.TryGetProperty("email", out var emailEl) && emailEl.ValueKind == JsonValueKind.String) + email = emailEl.GetString() ?? email; } - return (false, "Token không hợp lệ"); + // EN: Persist non-sensitive metadata for UI (no token stored in localStorage). + // VI: Lưu metadata không nhạy cảm cho UI (không lưu token trong localStorage). + try + { + await _js.InvokeVoidAsync("localStorage.setItem", StoredEmailKey, email); + await _js.InvokeVoidAsync("localStorage.setItem", StoredRoleKey, resolvedRole); + } + catch { /* localStorage not available on pre-render */ } + + _authState.Login(email, resolvedRole, expiresAt); + return (true, null); } var errorContent = await response.Content.ReadAsStringAsync(); - if (errorContent.Contains("invalid_grant")) + try { - return (false, "Email hoặc mật khẩu không đúng"); + using var errDoc = JsonDocument.Parse(errorContent); + if (errDoc.RootElement.TryGetProperty("message", out var msg)) + return (false, msg.GetString()); } + catch { } + return (false, $"Đăng nhập thất bại ({response.StatusCode})"); } catch (Exception ex) @@ -168,72 +147,69 @@ public class AuthService } /// - /// EN: Get stored access token. - /// VI: Lấy access token đã lưu. - /// - public async Task GetTokenAsync(string role = "owner") - { - try - { - var token = await _js.InvokeAsync("localStorage.getItem", TokenKey(role)); - if (string.IsNullOrEmpty(token)) - token = await _js.InvokeAsync("localStorage.getItem", LegacyTokenKey); - return token; - } - catch - { - return _authState.Token; - } - } - - /// - /// EN: Try to restore session from localStorage on app start. - /// VI: Khôi phục session từ localStorage khi app khởi động. - /// - /// - /// 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ũ. + /// EN: Try to restore session from the BFF session endpoint (reads httpOnly cookie server-side). + /// Falls back to localStorage metadata for email/role display only. + /// VI: Khôi phục session từ BFF session endpoint (đọc httpOnly cookie phía server). + /// Fallback sang localStorage metadata chỉ để hiển thị email/role. /// 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("localStorage.getItem", TokenKey(role)); - var email = await _js.InvokeAsync("localStorage.getItem", UserEmailKey(role)); - - if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(email)) + // EN: Ask BFF whether we have a valid session (reads httpOnly cookie). + // VI: Hỏi BFF xem có session hợp lệ không (đọc httpOnly cookie). + var response = await _http.GetAsync("/api/bff/auth/session"); + if (response.IsSuccessStatusCode) { - // 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); - } + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); - if (!string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(email)) - { - _authState.Login(email, token, role); + if (doc.RootElement.TryGetProperty("data", out var data)) + { + var email = data.TryGetProperty("email", out var eProp) ? eProp.GetString() : null; + var resolvedRole = data.TryGetProperty("role", out var rProp) ? rProp.GetString() : role; + DateTime? expiresAt = null; + if (data.TryGetProperty("expiresAt", out var expEl) && expEl.ValueKind != JsonValueKind.Null) + expiresAt = expEl.GetDateTime(); + + // EN: Fallback to localStorage for email if BFF didn't return it (shouldn't happen). + // VI: Fallback localStorage để lấy email nếu BFF không trả về (không nên xảy ra). + if (string.IsNullOrEmpty(email)) + { + try { email = await _js.InvokeAsync("localStorage.getItem", StoredEmailKey); } + catch { } + } + + if (!string.IsNullOrEmpty(email)) + _authState.Login(email, resolvedRole ?? role, expiresAt); + } } + // EN: 401 from BFF means no valid session — this is expected (not an error). + // VI: 401 từ BFF nghĩa là không có session hợp lệ — đây là bình thường (không phải lỗi). } - catch { /* localStorage not available */ } + catch { /* Network unavailable or pre-render — silently skip */ } } /// - /// EN: Logout and clear stored data. - /// VI: Đăng xuất và xóa dữ liệu đã lưu. + /// EN: Logout — clears the BFF session cookie and local state. + /// VI: Đăng xuất — xóa BFF session cookie và trạng thái cục bộ. /// 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); + // EN: Tell BFF to clear the httpOnly session cookie. + // VI: Yêu cầu BFF xóa httpOnly session cookie. + await _http.PostAsync("/api/bff/auth/logout", null); + } + catch { /* Best-effort: proceed with local logout even if BFF is unavailable */ } + + // EN: Clear non-sensitive localStorage metadata. + // VI: Xóa metadata localStorage không nhạy cảm. + try + { + await _js.InvokeVoidAsync("localStorage.removeItem", StoredEmailKey); + await _js.InvokeVoidAsync("localStorage.removeItem", StoredRoleKey); } catch { } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs index 756d40eb..e8f5fb26 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs @@ -1,41 +1,175 @@ +// EN: Auth state service — holds current session state in memory. +// Token is NOT stored here (it lives in the httpOnly BFF cookie). +// Tracks token expiry and fires OnTokenExpiring 2 minutes before expiry for proactive refresh. +// VI: Service trạng thái auth — lưu trạng thái session hiện tại trong memory. +// Token KHÔNG được lưu ở đây (nó ở trong httpOnly BFF cookie). +// Theo dõi thời hạn token và kích hoạt OnTokenExpiring trước 2 phút để refresh chủ động. + namespace WebClientTpos.Client.Services; -public class AuthStateService +/// +/// EN: Singleton auth state service. Tracks authentication state, role, and token expiry. +/// Does NOT store the raw access token — that is managed server-side via httpOnly cookie. +/// VI: Singleton service trạng thái auth. Theo dõi trạng thái xác thực, role, và thời hạn token. +/// KHÔNG lưu access token thô — được quản lý phía server qua httpOnly cookie. +/// +public class AuthStateService : IDisposable { + // ═══════════════════════════════════════════════════════════════════ + // EN: Public state + // VI: Trạng thái công khai + // ═══════════════════════════════════════════════════════════════════ + public bool IsAuthenticated { get; private set; } public string? UserEmail { get; private set; } - public string? UserRole { get; private set; } // "owner", "staff", "customer", "branch" - public string? Token { get; private set; } + /// + /// EN: User role — "owner", "staff", "customer", "branch". + /// VI: Vai trò người dùng — "owner", "staff", "customer", "branch". + /// + public string? UserRole { get; private set; } + + /// + /// EN: UTC time when the session cookie expires. Null if unknown. + /// Used to schedule proactive token refresh. + /// VI: Thời điểm UTC khi session cookie hết hạn. Null nếu không biết. + /// Dùng để lên lịch refresh token chủ động. + /// + public DateTime? TokenExpiry { get; private set; } + + // ═══════════════════════════════════════════════════════════════════ + // EN: Events + // VI: Sự kiện + // ═══════════════════════════════════════════════════════════════════ + + /// + /// EN: Fired whenever authentication state changes (login, logout, refresh). + /// VI: Kích hoạt khi trạng thái xác thực thay đổi (đăng nhập, đăng xuất, refresh). + /// public event Action? OnChange; - public void Login(string email, string token, string role) + /// + /// EN: Fired ~2 minutes before token expiry to trigger a proactive refresh. + /// Subscribers (e.g. AuthService) should call TryRestoreSessionAsync to renew the cookie. + /// VI: Kích hoạt ~2 phút trước khi token hết hạn để trigger refresh chủ động. + /// Subscriber (ví dụ AuthService) nên gọi TryRestoreSessionAsync để gia hạn cookie. + /// + public event Action? OnTokenExpiring; + + // ═══════════════════════════════════════════════════════════════════ + // EN: Timer for proactive refresh + // VI: Timer để refresh chủ động + // ═══════════════════════════════════════════════════════════════════ + + private Timer? _refreshTimer; + + // EN: Fire refresh event this many seconds before actual expiry. + // VI: Kích hoạt sự kiện refresh trước thời điểm hết hạn thực tế bao nhiêu giây. + private const int RefreshLeadSeconds = 120; + + // ═══════════════════════════════════════════════════════════════════ + // EN: State mutations + // VI: Thay đổi trạng thái + // ═══════════════════════════════════════════════════════════════════ + + /// + /// EN: Record a successful login. Token is managed by the BFF httpOnly cookie — not stored here. + /// VI: Ghi nhận đăng nhập thành công. Token được quản lý bởi BFF httpOnly cookie — không lưu ở đây. + /// + public void Login(string email, string role, DateTime? expiresAt = null) { - if (IsAuthenticated && Token == token && UserEmail == email) + if (IsAuthenticated && UserEmail == email && UserRole == role && TokenExpiry == expiresAt) return; IsAuthenticated = true; UserEmail = email; - Token = token; UserRole = role; + TokenExpiry = expiresAt; + + ScheduleRefresh(expiresAt); OnChange?.Invoke(); } + /// + /// EN: Clear session state on logout or expiry. + /// VI: Xóa trạng thái session khi đăng xuất hoặc hết hạn. + /// public void Logout() { IsAuthenticated = false; UserEmail = null; - Token = null; UserRole = null; + TokenExpiry = null; + + CancelRefreshTimer(); OnChange?.Invoke(); } + // ═══════════════════════════════════════════════════════════════════ + // EN: Token expiry scheduling + // VI: Lên lịch hết hạn token + // ═══════════════════════════════════════════════════════════════════ + + private void ScheduleRefresh(DateTime? expiresAt) + { + CancelRefreshTimer(); + + if (!expiresAt.HasValue) + return; + + // EN: Calculate when to fire the refresh event (lead time before actual expiry). + // VI: Tính thời điểm kích hoạt sự kiện refresh (trước thời điểm hết hạn thực tế). + var now = DateTime.UtcNow; + var fireAt = expiresAt.Value.AddSeconds(-RefreshLeadSeconds); + var delay = fireAt - now; + + // EN: If already within lead window (or expired), fire immediately. + // VI: Nếu đã trong cửa sổ lead (hoặc hết hạn), kích hoạt ngay lập tức. + if (delay <= TimeSpan.Zero) + { + OnTokenExpiring?.Invoke(); + return; + } + + _refreshTimer = new Timer(_ => + { + OnTokenExpiring?.Invoke(); + CancelRefreshTimer(); + }, null, delay, Timeout.InfiniteTimeSpan); + } + + private void CancelRefreshTimer() + { + _refreshTimer?.Dispose(); + _refreshTimer = null; + } + + // ═══════════════════════════════════════════════════════════════════ + // EN: Navigation helpers + // VI: Helper điều hướng + // ═══════════════════════════════════════════════════════════════════ + + /// + /// EN: Returns the portal URL for the current user role. + /// VI: Trả về URL portal cho vai trò người dùng hiện tại. + /// public string GetPortalUrl() => UserRole switch { "owner" or "admin" => "/admin", - "staff" => "/staff/dashboard", - "branch" => "/admin", - "customer" => "/app", - _ => "/auth/login" + "staff" => "/staff/dashboard", + "branch" => "/admin", + "customer" => "/app", + _ => "/auth/login" }; + + // ═══════════════════════════════════════════════════════════════════ + // EN: IDisposable + // VI: IDisposable + // ═══════════════════════════════════════════════════════════════════ + + public void Dispose() + { + CancelRefreshTimer(); + GC.SuppressFinalize(this); + } } diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs index 76800fd1..335295e6 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs @@ -1,16 +1,19 @@ -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; namespace WebClientTpos.Client.Services; -// EN: POS data service — attaches auth token for multi-tenant data isolation. -// VI: POS data service — đính kèm auth token để cách ly dữ liệu multi-tenant. +// EN: POS data service — all BFF requests are authenticated via httpOnly session cookie. +// The browser automatically includes the cookie in same-origin requests (SEC-W-01 / FRONT-W-02). +// No manual token attachment — eliminates the DefaultRequestHeaders race condition. +// VI: POS data service — tất cả request BFF được xác thực qua httpOnly session cookie. +// Trình duyệt tự động đính kèm cookie cho các request cùng origin (SEC-W-01 / FRONT-W-02). +// Không gắn token thủ công — loại bỏ race condition trên DefaultRequestHeaders. public class PosDataService { private readonly HttpClient _http; - private readonly AuthStateService _authState; + // EN: Read options — case-insensitive to handle camelCase responses from BFF/microservices. // VI: Options đọc — không phân biệt hoa thường để xử lý camelCase responses từ BFF/microservices. private static readonly JsonSerializerOptions _jsonOptions = new() @@ -19,32 +22,25 @@ public class PosDataService DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - // EN: Write options — camelCase to match ASP.NET model binding defaults - // VI: Options ghi — camelCase để khớp với ASP.NET model binding mặc định + // EN: Write options — camelCase to match ASP.NET model binding defaults. + // VI: Options ghi — camelCase để khớp với ASP.NET model binding mặc định. private static readonly JsonSerializerOptions _writeOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - public PosDataService(HttpClient http, AuthStateService authState) + public PosDataService(HttpClient http) { _http = http; - _authState = authState; } - /// - /// EN: Ensure Authorization header is set before each BFF call. - /// VI: Đảm bảo header Authorization được đặt trước mỗi BFF call. - /// - private void AttachToken() - { - if (!string.IsNullOrEmpty(_authState.Token)) - { - _http.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", _authState.Token); - } - } + // EN: Helper — create a per-request HttpRequestMessage to avoid DefaultRequestHeaders mutation. + // Auth is handled automatically by the browser via the httpOnly BFF session cookie. + // VI: Helper — tạo HttpRequestMessage per-request để tránh mutation DefaultRequestHeaders. + // Auth được trình duyệt tự xử lý qua httpOnly BFF session cookie. + private static HttpRequestMessage NewRequest(HttpMethod method, string url) + => new(method, url); private static async Task TryExtractError(HttpResponseMessage resp) { @@ -66,13 +62,16 @@ public class PosDataService } /// - /// EN: Generic POST helper — sends JSON body to BFF endpoint. - /// VI: Helper POST chung — gui JSON body den BFF endpoint. + /// EN: Generic POST helper — sends JSON body to BFF endpoint via per-request HttpRequestMessage. + /// Uses per-request message (not DefaultRequestHeaders) to avoid race conditions (FRONT-W-02). + /// VI: Helper POST chung — gửi JSON body đến BFF endpoint qua HttpRequestMessage per-request. + /// Dùng per-request message (không phải DefaultRequestHeaders) để tránh race condition (FRONT-W-02). /// public async Task PostAsync(string url, object body) { - AttachToken(); - var resp = await _http.PostAsJsonAsync(url, body, _writeOptions); + var request = NewRequest(HttpMethod.Post, url); + request.Content = JsonContent.Create(body, options: _writeOptions); + var resp = await _http.SendAsync(request); return resp.IsSuccessStatusCode; } @@ -84,13 +83,14 @@ public class PosDataService => GetObjectFromApiAsync(url); /// - /// EN: Generic PUT helper — sends JSON body to BFF endpoint. - /// VI: Helper PUT chung — gửi JSON body đến BFF endpoint. + /// EN: Generic PUT helper — sends JSON body to BFF endpoint via per-request HttpRequestMessage. + /// VI: Helper PUT chung — gửi JSON body đến BFF endpoint qua HttpRequestMessage per-request. /// public async Task PutAsync(string url, object body) { - AttachToken(); - var resp = await _http.PutAsJsonAsync(url, body, _writeOptions); + var request = NewRequest(HttpMethod.Put, url); + request.Content = JsonContent.Create(body, options: _writeOptions); + var resp = await _http.SendAsync(request); return resp.IsSuccessStatusCode; } @@ -100,8 +100,9 @@ public class PosDataService /// public async Task PostAndGetAsync(string url, object body) where T : class { - AttachToken(); - var resp = await _http.PostAsJsonAsync(url, body, _writeOptions); + var request = NewRequest(HttpMethod.Post, url); + request.Content = JsonContent.Create(body, options: _writeOptions); + var resp = await _http.SendAsync(request); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(json)) return null; @@ -117,12 +118,13 @@ public class PosDataService /// /// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes. + /// Uses per-request HttpRequestMessage to avoid DefaultRequestHeaders race condition (FRONT-W-02). /// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope. + /// Dùng HttpRequestMessage per-request để tránh race condition DefaultRequestHeaders (FRONT-W-02). /// public async Task> GetListFromApiAsync(string url) { - AttachToken(); - var resp = await _http.GetAsync(url); + var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, url)); if (!resp.IsSuccessStatusCode) return new(); var json = await resp.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(json)) return new(); @@ -160,12 +162,13 @@ public class PosDataService /// /// EN: Robust single-object deserialization — handles plain objects and ApiResponse envelopes. + /// Uses per-request HttpRequestMessage (FRONT-W-02). /// VI: Deserialize đối tượng đơn linh hoạt — xử lý object thuần và ApiResponse envelope. + /// Dùng HttpRequestMessage per-request (FRONT-W-02). /// private async Task GetObjectFromApiAsync(string url) where T : class { - AttachToken(); - var resp = await _http.GetAsync(url); + var resp = await _http.SendAsync(NewRequest(HttpMethod.Get, url)); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(json)) return null; @@ -299,21 +302,18 @@ public class PosDataService public async Task CreateProductAsync(CreateProductRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/products", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task UpdateProductAsync(Guid productId, CreateProductRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/products/{productId}", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task DeleteProductAsync(Guid productId) { - AttachToken(); var resp = await _http.DeleteAsync($"api/bff/products/{productId}"); return resp.IsSuccessStatusCode; } @@ -342,14 +342,12 @@ public class PosDataService // VI: Tạo mặt hàng tồn kho mới (nguyên liệu, thành phẩm, hoặc vật tư). public async Task CreateInventoryItemAsync(CreateInventoryItemRequest req) { - AttachToken(); - var r = await _http.PostAsJsonAsync("api/bff/inventory/items", req, _writeOptions); + var r= await _http.PostAsJsonAsync("api/bff/inventory/items", req, _writeOptions); return r.IsSuccessStatusCode; } public async Task DeleteInventoryItemAsync(Guid inventoryItemId) { - AttachToken(); var resp = await _http.DeleteAsync($"api/bff/inventory/items/{inventoryItemId}"); return resp.IsSuccessStatusCode; } @@ -374,21 +372,18 @@ public class PosDataService public async Task CreateStaffAsync(CreateStaffRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task UpdateStaffAsync(Guid staffId, CreateStaffRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/staff/{staffId}", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task DeleteStaffAsync(Guid staffId) { - AttachToken(); var resp = await _http.DeleteAsync($"api/bff/staff/{staffId}"); return resp.IsSuccessStatusCode; } @@ -397,7 +392,6 @@ public class PosDataService public async Task UpdateInventoryAsync(Guid inventoryId, UpdateInventoryRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/inventory/{inventoryId}", req, _writeOptions); return resp.IsSuccessStatusCode; } @@ -467,28 +461,23 @@ public class PosDataService public async Task> GetCampaignsAsync() { - AttachToken(); - var resp = await _http.GetFromJsonAsync("api/bff/campaigns", _jsonOptions); - return resp?.Items ?? new(); + return await GetListFromApiAsync("api/bff/campaigns"); } public async Task CreateCampaignAsync(CreateCampaignRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/campaigns", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task UpdateCampaignAsync(Guid campaignId, CreateCampaignRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/campaigns/{campaignId}", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task DeleteCampaignAsync(Guid campaignId) { - AttachToken(); var resp = await _http.DeleteAsync($"api/bff/campaigns/{campaignId}"); return resp.IsSuccessStatusCode; } @@ -502,7 +491,6 @@ public class PosDataService public async Task<(bool Ok, string? Error)> CreateMemberAsync(CreateMemberRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/members", req, _writeOptions); if (resp.IsSuccessStatusCode) return (true, null); var err = await TryExtractError(resp); @@ -511,14 +499,12 @@ public class PosDataService public async Task UpdateMemberAsync(Guid memberId, UpdateMemberRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/members/{memberId}", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task DeleteMemberAsync(Guid memberId) { - AttachToken(); var resp = await _http.DeleteAsync($"api/bff/members/{memberId}"); return resp.IsSuccessStatusCode; } @@ -542,7 +528,6 @@ public class PosDataService public async Task StockInAsync(StockInRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-in", req, _writeOptions); return resp.IsSuccessStatusCode; } @@ -551,7 +536,6 @@ public class PosDataService public async Task StockOutAsync(StockOutRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/inventory/stock-out", req, _writeOptions); return resp.IsSuccessStatusCode; } @@ -560,7 +544,6 @@ public class PosDataService public async Task AdjustStockAsync(AdjustStockRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/inventory/adjust", req, _writeOptions); return resp.IsSuccessStatusCode; } @@ -579,7 +562,6 @@ public class PosDataService public async Task RecordWastageAsync(RecordWastageRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/inventory/wastage", req, _writeOptions); return resp.IsSuccessStatusCode; } @@ -591,7 +573,6 @@ public class PosDataService public async Task StocktakeAsync(StocktakeRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/inventory/stocktake", req, _writeOptions); if (!resp.IsSuccessStatusCode) return null; try @@ -645,7 +626,6 @@ public class PosDataService public async Task<(bool Ok, string? Error)> CreateLevelAsync(CreateLevelRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/membership/levels", req, _writeOptions); if (resp.IsSuccessStatusCode) return (true, null); return (false, await TryExtractError(resp)); @@ -653,7 +633,6 @@ public class PosDataService public async Task<(bool Ok, string? Error)> UpdateLevelAsync(Guid levelId, CreateLevelRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/membership/levels/{levelId}", req, _writeOptions); if (resp.IsSuccessStatusCode) return (true, null); return (false, await TryExtractError(resp)); @@ -661,7 +640,6 @@ public class PosDataService public async Task DeleteLevelAsync(Guid levelId) { - AttachToken(); var resp = await _http.DeleteAsync($"api/bff/membership/levels/{levelId}"); return resp.IsSuccessStatusCode; } @@ -679,7 +657,6 @@ public class PosDataService public async Task AddExperienceAsync(Guid memberId, AddExpRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync($"api/bff/members/{memberId}/experience", req, _writeOptions); if (resp.IsSuccessStatusCode) return await resp.Content.ReadFromJsonAsync(_jsonOptions); @@ -696,7 +673,8 @@ public class PosDataService public async Task UploadImageAsync(Stream fileStream, string fileName, string contentType) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. using var content = new MultipartFormDataContent(); var streamContent = new StreamContent(fileStream); streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); @@ -761,7 +739,8 @@ public class PosDataService public async Task GetPosDashboardAsync(Guid shopId, string? period = "today") { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. return await _http.GetFromJsonAsync( $"api/bff/pos/dashboard?shopId={shopId}&period={period ?? "today"}", _jsonOptions) ?? new(0, 0, 0, 0, new(), new(), new(), new()); @@ -778,7 +757,6 @@ public class PosDataService public async Task CreatePosOrderAsync(CreatePosOrderRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/pos/orders", req, _writeOptions); if (resp.IsSuccessStatusCode) return await resp.Content.ReadFromJsonAsync(_jsonOptions); @@ -801,7 +779,8 @@ public class PosDataService Guid orderId, Guid shopId, string paymentMethod, decimal? amountTendered = null, string? returnUrl = null) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var body = new { PaymentMethod = paymentMethod, AmountTendered = amountTendered, ReturnUrl = returnUrl }; var resp = await _http.PostAsJsonAsync($"api/bff/orders/{orderId}/pay?shopId={shopId}", body, _writeOptions); if (resp.IsSuccessStatusCode) @@ -856,7 +835,6 @@ public class PosDataService public async Task> GetActiveTableOrdersAsync(Guid shopId) { - AttachToken(); var resp = await _http.GetAsync($"api/bff/orders/active-by-table?shopId={shopId}"); if (resp.IsSuccessStatusCode) return await resp.Content.ReadFromJsonAsync>(_jsonOptions) ?? new(); @@ -871,21 +849,18 @@ public class PosDataService public async Task CreateCategoryAsync(AdminCreateCategoryRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/categories", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task UpdateCategoryAsync(Guid categoryId, AdminCreateCategoryRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/categories/{categoryId}", req, _writeOptions); return resp.IsSuccessStatusCode; } public async Task DeleteCategoryAsync(Guid categoryId) { - AttachToken(); var resp = await _http.DeleteAsync($"api/bff/categories/{categoryId}"); return resp.IsSuccessStatusCode; } @@ -900,14 +875,16 @@ public class PosDataService public async Task GetOrderDetailAsync(Guid orderId, Guid? shopId = null) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var qs = shopId.HasValue ? $"?shopId={shopId}" : ""; return await _http.GetFromJsonAsync($"api/bff/orders/{orderId}{qs}", _jsonOptions); } public async Task CancelOrderAsync(Guid orderId) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. using var req = new HttpRequestMessage(HttpMethod.Put, $"api/bff/orders/{orderId}/cancel"); req.Headers.Authorization = _http.DefaultRequestHeaders.Authorization; var resp = await _http.SendAsync(req); @@ -922,7 +899,6 @@ public class PosDataService public async Task UpdateShopAsync(Guid shopId, UpdateShopRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}", req, _writeOptions); return resp.IsSuccessStatusCode; } @@ -967,7 +943,6 @@ public class PosDataService public async Task UpdateShopSettingsAsync(Guid shopId, UpdateShopSettingsRequest req) { - AttachToken(); var resp = await _http.PutAsJsonAsync($"api/bff/shops/{shopId}/settings", req, _writeOptions); return resp.IsSuccessStatusCode; } @@ -1053,7 +1028,8 @@ public class PosDataService // EN: Cancel appointment with reason / VI: Hủy lịch hẹn kèm lý do public async Task CancelAppointmentWithReasonAsync(Guid apptId, string reason) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var request = new HttpRequestMessage(HttpMethod.Delete, $"api/bff/appointments/{apptId}/cancel") { Content = JsonContent.Create(new { reason }, options: _writeOptions) @@ -1105,7 +1081,8 @@ public class PosDataService public async Task UpdateTicketStatusAsync(Guid ticketId, UpdateTicketStatusRequest req) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/kitchen/tickets/{ticketId}/status") { Content = JsonContent.Create(req, options: _writeOptions) @@ -1152,7 +1129,6 @@ public class PosDataService public async Task RedeemVoucherAsync(Guid voucherId, decimal amount) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/vouchers/redeem", new { voucherId, amount }, _writeOptions); return resp.IsSuccessStatusCode; } @@ -1167,7 +1143,8 @@ public class PosDataService public async Task> GetAdminVouchersAsync(Guid? campaignId = null, string? status = null, int pageSize = 50) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var qs = $"pageSize={pageSize}"; if (campaignId.HasValue) qs += $"&campaignId={campaignId}"; if (!string.IsNullOrEmpty(status)) qs += $"&status={status}"; @@ -1192,7 +1169,6 @@ public class PosDataService public async Task<(bool Ok, string? Error)> InviteStaffWithAccountAsync(InviteStaffWithAccountRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/staff/invite-with-account", req, _writeOptions); if (resp.IsSuccessStatusCode) return (true, null); var err = await TryExtractError(resp); @@ -1211,7 +1187,8 @@ public class PosDataService public async Task> GetStorageFilesAsync(int skip = 0, int take = 50, string? search = null) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var qs = $"?skip={skip}&take={take}"; if (!string.IsNullOrEmpty(search)) qs += $"&search={Uri.EscapeDataString(search)}"; var wrapper = await _http.GetFromJsonAsync>($"api/bff/files{qs}", _jsonOptions); @@ -1223,7 +1200,6 @@ public class PosDataService public async Task GetDownloadUrlAsync(Guid fileId) { - AttachToken(); var resp = await _http.GetAsync($"api/bff/files/{fileId}/download-url"); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadFromJsonAsync(_jsonOptions); @@ -1238,7 +1214,8 @@ public class PosDataService public async Task> GetFoldersAsync(Guid? parentId = null) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var qs = parentId.HasValue ? $"?parentId={parentId}" : ""; var wrapper = await _http.GetFromJsonAsync>>($"api/bff/folders{qs}", _jsonOptions); return wrapper?.Data?.ToList() ?? new(); @@ -1246,7 +1223,6 @@ public class PosDataService public async Task<(bool Ok, string? Error)> CreateFolderAsync(CreateFolderRequest req) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/folders", req, _writeOptions); if (resp.IsSuccessStatusCode) return (true, null); var err = await TryExtractError(resp); @@ -1258,7 +1234,6 @@ public class PosDataService public async Task UploadFileRawAsync(MultipartFormDataContent content) { - AttachToken(); var resp = await _http.PostAsync("api/bff/files/upload?accessLevel=public", content); return resp.IsSuccessStatusCode; } @@ -1267,7 +1242,6 @@ public class PosDataService public async Task GenerateTableQrTokenAsync(Guid tableId) { - AttachToken(); var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/generate-qr", new { }, _writeOptions); if (resp.IsSuccessStatusCode) { @@ -1282,7 +1256,6 @@ public class PosDataService public async Task GetTableByTokenAsync(string token) { - AttachToken(); var resp = await _http.GetAsync($"api/bff/tables/by-token/{token}"); if (resp.IsSuccessStatusCode) { @@ -1312,7 +1285,8 @@ public class PosDataService public async Task UpdateReservationStatusAsync(Guid reservationId, string status) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/reservations/{reservationId}/status") { Content = JsonContent.Create(new { status }, options: _writeOptions) @@ -1325,7 +1299,8 @@ public class PosDataService public async Task UpdateTableStatusAsync(Guid tableId, string status) { - AttachToken(); + // EN: Auth via httpOnly cookie — no manual token attachment needed. + // VI: Auth qua httpOnly cookie — không cần gắn token thủ công. var request = new HttpRequestMessage(HttpMethod.Patch, $"api/bff/tables/{tableId}/status") { Content = JsonContent.Create(new { status }, options: _writeOptions) @@ -1340,7 +1315,6 @@ public class PosDataService public async Task OpenSessionAsync(Guid tableId, Guid shopId, int guestCount = 1) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/sessions", new { tableId, shopId, guestCount }, _writeOptions); if (resp.IsSuccessStatusCode) @@ -1357,7 +1331,6 @@ public class PosDataService public async Task GetSessionAsync(Guid sessionId) { - AttachToken(); var resp = await _http.GetAsync($"api/bff/sessions/{sessionId}"); if (resp.IsSuccessStatusCode) { @@ -1370,7 +1343,6 @@ public class PosDataService public async Task CloseSessionAsync(Guid sessionId) { - AttachToken(); var resp = await _http.PostAsJsonAsync($"api/bff/sessions/{sessionId}/close", new { }, _writeOptions); return resp.IsSuccessStatusCode; } @@ -1381,7 +1353,6 @@ public class PosDataService /// public async Task ExtendSessionAsync(Guid tableId, int additionalMinutes) { - AttachToken(); var resp = await _http.PostAsJsonAsync($"api/bff/tables/{tableId}/extend", new { additionalMinutes }, _writeOptions); // EN: Fallback — if dedicated extend endpoint doesn't exist, use PATCH status to signal extension @@ -1402,7 +1373,6 @@ public class PosDataService public async Task PublishShopAsync(Guid shopId) { - AttachToken(); var resp = await _http.PostAsync($"api/bff/shops/{shopId}/publish", null); return resp.IsSuccessStatusCode; } @@ -1561,7 +1531,6 @@ public class PosDataService /// public async Task> GetStockLevelsAsync(Guid shopId, List productIds) { - AttachToken(); var resp = await _http.PostAsJsonAsync($"api/bff/shops/{shopId}/inventory/stock-levels", new { productIds }, _writeOptions); if (!resp.IsSuccessStatusCode) return new(); @@ -1582,7 +1551,6 @@ public class PosDataService /// public async Task<(bool Ok, string? Error)> CreateReturnAsync(Guid shopId, Guid originalOrderId, List items, string reason) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/orders/returns", new { shopId, originalOrderId, items, reason }, _writeOptions); if (resp.IsSuccessStatusCode) return (true, null); @@ -1596,7 +1564,6 @@ public class PosDataService public async Task<(bool Ok, string? Error)> CreateExchangeAsync(Guid shopId, Guid originalOrderId, List returnItems, List newItems, string reason) { - AttachToken(); var resp = await _http.PostAsJsonAsync("api/bff/orders/exchanges", new { shopId, originalOrderId, returnItems, newItems, reason }, _writeOptions); if (resp.IsSuccessStatusCode) return (true, null); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html index f1e9b2bf..7d1b8a01 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html +++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html @@ -15,8 +15,10 @@ - - + +