feat: implement merchant subscription management and enhanced user account/security features with a new BFF layer.
This commit is contained in:
@@ -3,10 +3,12 @@
|
||||
@inherits AdminBase
|
||||
@inject WebClientTpos.Client.Services.AuthStateService AuthState
|
||||
@inject WebClientTpos.Client.Services.PosDataService DataService
|
||||
@inject IJSRuntime JS
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@*
|
||||
EN: Admin settings page — account info, shop overview, general settings.
|
||||
VI: Trang cài đặt admin — thông tin tài khoản, tổng quan cửa hàng, cài đặt chung.
|
||||
EN: Admin settings page — account, security, subscription, notifications, system overview.
|
||||
VI: Trang cài đặt admin — tài khoản, bảo mật, gói dịch vụ, thông báo, tổng quan hệ thống.
|
||||
*@
|
||||
|
||||
<PageTitle>Cài đặt — GoodGo Admin</PageTitle>
|
||||
@@ -21,26 +23,455 @@
|
||||
|
||||
@* ═══ TABS ═══ *@
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab @(_tab == "general" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "general")">
|
||||
<i data-lucide="settings" style="width:14px;height:14px;"></i> Tổng quan
|
||||
</button>
|
||||
<button class="admin-tab @(_tab == "account" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "account")">
|
||||
<button class="admin-tab @(_tab == "account" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("account"))">
|
||||
<i data-lucide="user" style="width:14px;height:14px;"></i> Tài khoản
|
||||
</button>
|
||||
<button class="admin-tab @(_tab == "notifications" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "notifications")">
|
||||
<button class="admin-tab @(_tab == "security" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("security"))">
|
||||
<i data-lucide="lock" style="width:14px;height:14px;"></i> Bảo mật
|
||||
</button>
|
||||
<button class="admin-tab @(_tab == "subscription" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("subscription"))">
|
||||
<i data-lucide="crown" style="width:14px;height:14px;"></i> Gói dịch vụ
|
||||
</button>
|
||||
<button class="admin-tab @(_tab == "notifications" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("notifications"))">
|
||||
<i data-lucide="bell" style="width:14px;height:14px;"></i> Thông báo
|
||||
</button>
|
||||
<button class="admin-tab @(_tab == "security" ? "admin-tab--active" : "")" @onclick="@(() => _tab = "security")">
|
||||
<i data-lucide="lock" style="width:14px;height:14px;"></i> Bảo mật
|
||||
<button class="admin-tab @(_tab == "general" ? "admin-tab--active" : "")" @onclick="@(() => SwitchTab("general"))">
|
||||
<i data-lucide="settings" style="width:14px;height:14px;"></i> Hệ thống
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@* ═══ CONTENT ═══ *@
|
||||
<div class="admin-content" style="display:flex;flex-direction:column;gap:24px;">
|
||||
|
||||
@if (_tab == "general")
|
||||
@* ════════════════════════════════════════════════════════════════════
|
||||
TAB: TÀI KHOẢN — Profile + Merchant Info
|
||||
════════════════════════════════════════════════════════════════════ *@
|
||||
@if (_tab == "account")
|
||||
{
|
||||
@* ── Profile Info ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="user" style="color:var(--admin-orange-primary);"></i>
|
||||
Thông tin cá nhân
|
||||
</h3>
|
||||
@if (!_editingProfile)
|
||||
{
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="StartEditProfile">
|
||||
<i data-lucide="pencil" style="width:12px;height:12px;"></i> Chỉnh sửa
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
|
||||
@if (_loadingAccount)
|
||||
{
|
||||
<div style="text-align:center;padding:24px;"><MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Primary" /></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display:flex;align-items:center;gap:20px;padding-bottom:16px;border-bottom:1px solid var(--admin-border-subtle);">
|
||||
<div style="width:72px;height:72px;border-radius:50%;background:linear-gradient(135deg,var(--admin-orange-primary),#F59E0B);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:white;">
|
||||
@(string.IsNullOrEmpty(_firstName) ? (AuthState.UserEmail?[..1].ToUpper() ?? "U") : _firstName[..1].ToUpper())
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:18px;font-weight:700;color:var(--admin-text-primary);">
|
||||
@(string.IsNullOrEmpty(_firstName) && string.IsNullOrEmpty(_lastName)
|
||||
? (AuthState.UserEmail ?? "—")
|
||||
: $"{_firstName} {_lastName}".Trim())
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--admin-text-tertiary);">@(AuthState.UserEmail ?? "—")</div>
|
||||
<div class="admin-status-badge admin-status-badge--online" style="width:fit-content;margin-top:4px;">
|
||||
<span class="admin-status-badge__dot"></span> Chủ doanh nghiệp
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Họ</label>
|
||||
<input class="admin-form-input" @bind="_lastName" readonly="@(!_editingProfile)" />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Tên</label>
|
||||
<input class="admin-form-input" @bind="_firstName" readonly="@(!_editingProfile)" />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Email</label>
|
||||
<input class="admin-form-input" value="@(AuthState.UserEmail ?? "—")" readonly />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Múi giờ</label>
|
||||
<input class="admin-form-input" @bind="_timezone" readonly="@(!_editingProfile)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Giới thiệu</label>
|
||||
<textarea class="admin-form-input" rows="2" @bind="_bio" readonly="@(!_editingProfile)" style="resize:vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
@if (_editingProfile)
|
||||
{
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="CancelEditProfile">Hủy</button>
|
||||
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="SaveProfile" disabled="@_saving">
|
||||
@if (_saving) { <MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Surface" Style="width:14px;height:14px;" /> }
|
||||
else { <span>Lưu thay đổi</span> }
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_profileMsg))
|
||||
{
|
||||
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_profileMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_profileMsgOk ? "#22C55E" : "#EF4444");">
|
||||
@_profileMsg
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Merchant Info ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="building-2" style="color:#8B5CF6;"></i>
|
||||
Thông tin doanh nghiệp
|
||||
</h3>
|
||||
@if (!_editingMerchant)
|
||||
{
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="() => _editingMerchant = true">
|
||||
<i data-lucide="pencil" style="width:12px;height:12px;"></i> Chỉnh sửa
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Tên doanh nghiệp</label>
|
||||
<input class="admin-form-input" @bind="_businessName" readonly="@(!_editingMerchant)" />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Loại hình</label>
|
||||
<input class="admin-form-input" value="@_merchantType" readonly />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Mã số thuế</label>
|
||||
<input class="admin-form-input" @bind="_taxId" readonly="@(!_editingMerchant)" placeholder="Nhập MST..." />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Trạng thái xác minh</label>
|
||||
<div class="admin-status-badge @(_verificationStatus == "Verified" ? "admin-status-badge--online" : "admin-status-badge--warning")" style="width:fit-content;">
|
||||
<span class="admin-status-badge__dot"></span>
|
||||
@(_verificationStatus == "Verified" ? "Đã xác minh" : _verificationStatus == "Pending" ? "Đang chờ" : "Chưa xác minh")
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Số cửa hàng</label>
|
||||
<input class="admin-form-input" value="@_shopCount" readonly />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Ngày đăng ký</label>
|
||||
<input class="admin-form-input" value="@_merchantCreatedAt" readonly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_editingMerchant)
|
||||
{
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 14px;" @onclick="() => _editingMerchant = false">Hủy</button>
|
||||
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="SaveMerchant" disabled="@_saving">Lưu</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_merchantMsg))
|
||||
{
|
||||
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_merchantMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_merchantMsgOk ? "#22C55E" : "#EF4444");">
|
||||
@_merchantMsg
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ════════════════════════════════════════════════════════════════════
|
||||
TAB: BẢO MẬT — Password, 2FA, Sessions
|
||||
════════════════════════════════════════════════════════════════════ *@
|
||||
else if (_tab == "security")
|
||||
{
|
||||
@* ── Change Password ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="key" style="color:var(--admin-orange-primary);"></i>
|
||||
Đổi mật khẩu
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Mật khẩu hiện tại</label>
|
||||
<input class="admin-form-input" type="password" @bind="_currentPassword" placeholder="••••••••" />
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Mật khẩu mới</label>
|
||||
<input class="admin-form-input" type="password" @bind="_newPassword" placeholder="Tối thiểu 8 ký tự" />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Xác nhận mật khẩu mới</label>
|
||||
<input class="admin-form-input" type="password" @bind="_confirmPassword" placeholder="Nhập lại mật khẩu mới" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;">
|
||||
<button class="admin-btn-primary" style="font-size:12px;padding:8px 20px;" @onclick="ChangePassword" disabled="@_saving">
|
||||
Đổi mật khẩu
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_pwMsg))
|
||||
{
|
||||
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_pwMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_pwMsgOk ? "#22C55E" : "#EF4444");">@_pwMsg</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── 2FA ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="shield-check" style="color:#22C55E;"></i>
|
||||
Xác thực 2 bước (2FA)
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div>
|
||||
<span style="font-size:13px;font-weight:600;">Trạng thái 2FA</span>
|
||||
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Bảo mật tài khoản bằng mã OTP từ ứng dụng Authenticator</p>
|
||||
</div>
|
||||
@if (_twoFactorEnabled)
|
||||
{
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div class="admin-status-badge admin-status-badge--online"><span class="admin-status-badge__dot"></span> Đã bật</div>
|
||||
<button class="admin-btn-secondary" style="font-size:11px;padding:4px 10px;color:#EF4444;" @onclick="StartDisable2FA">Tắt 2FA</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="admin-btn-primary" style="font-size:12px;padding:6px 14px;" @onclick="StartEnable2FA" disabled="@_saving">Bật 2FA</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_show2FASetup)
|
||||
{
|
||||
<div style="padding:16px;background:var(--admin-bg-interactive);border-radius:10px;display:flex;flex-direction:column;gap:12px;">
|
||||
<p style="font-size:13px;color:var(--admin-text-secondary);margin:0;">Quét mã QR bằng Google Authenticator hoặc Authy:</p>
|
||||
@if (!string.IsNullOrEmpty(_qrCodeBase64))
|
||||
{
|
||||
<div style="text-align:center;">
|
||||
<img src="@_qrCodeBase64" alt="QR Code" style="width:180px;height:180px;border-radius:8px;background:white;padding:8px;" />
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_manualKey))
|
||||
{
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);text-align:center;">
|
||||
Mã thủ công: <code style="background:var(--admin-bg-elevated);padding:2px 6px;border-radius:4px;">@_manualKey</code>
|
||||
</div>
|
||||
}
|
||||
<div class="admin-form-group" style="max-width:300px;margin:0 auto;">
|
||||
<label class="admin-form-label">Nhập mã 6 số từ app:</label>
|
||||
<input class="admin-form-input" @bind="_totpCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:20px;letter-spacing:8px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:center;">
|
||||
<button class="admin-btn-secondary" style="font-size:12px;" @onclick="() => _show2FASetup = false">Hủy</button>
|
||||
<button class="admin-btn-primary" style="font-size:12px;" @onclick="Verify2FA" disabled="@_saving">Xác nhận</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_showDisable2FA)
|
||||
{
|
||||
<div style="padding:16px;background:var(--admin-bg-interactive);border-radius:10px;display:flex;flex-direction:column;gap:12px;">
|
||||
<p style="font-size:13px;color:var(--admin-text-secondary);margin:0;">Nhập mã OTP hiện tại để tắt 2FA:</p>
|
||||
<div class="admin-form-group" style="max-width:300px;margin:0 auto;">
|
||||
<input class="admin-form-input" @bind="_totpCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:20px;letter-spacing:8px;" />
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:center;">
|
||||
<button class="admin-btn-secondary" style="font-size:12px;" @onclick="() => _showDisable2FA = false">Hủy</button>
|
||||
<button class="admin-btn-primary" style="font-size:12px;background:#EF4444;" @onclick="Disable2FA" disabled="@_saving">Tắt 2FA</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_tfaMsg))
|
||||
{
|
||||
<div style="padding:8px 12px;border-radius:8px;font-size:12px;background:@(_tfaMsgOk ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)");color:@(_tfaMsgOk ? "#22C55E" : "#EF4444");">@_tfaMsg</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Linked Accounts ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="link" style="color:#3B82F6;"></i>
|
||||
Tài khoản liên kết
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="width:28px;height:28px;border-radius:6px;background:#4285F4;display:flex;align-items:center;justify-content:center;">
|
||||
<span style="color:white;font-weight:700;font-size:14px;">G</span>
|
||||
</div>
|
||||
<span style="font-size:13px;font-weight:500;">Google</span>
|
||||
</div>
|
||||
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(_linkedGoogle ? "Đã liên kết" : "Chưa liên kết")</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div style="width:28px;height:28px;border-radius:6px;background:#1877F2;display:flex;align-items:center;justify-content:center;">
|
||||
<span style="color:white;font-weight:700;font-size:14px;">f</span>
|
||||
</div>
|
||||
<span style="font-size:13px;font-weight:500;">Facebook</span>
|
||||
</div>
|
||||
<span style="font-size:12px;color:var(--admin-text-tertiary);">@(_linkedFacebook ? "Đã liên kết" : "Chưa liên kết")</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Danger Zone ── *@
|
||||
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title" style="color:#EF4444;">
|
||||
<i data-lucide="alert-triangle" style="color:#EF4444;"></i>
|
||||
Vùng nguy hiểm
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<span style="font-size:13px;font-weight:600;">Xóa tài khoản</span>
|
||||
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu</p>
|
||||
</div>
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;color:#EF4444;border-color:rgba(239,68,68,0.3);">Xóa tài khoản</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ════════════════════════════════════════════════════════════════════
|
||||
TAB: GÓI DỊCH VỤ — Subscription plans
|
||||
════════════════════════════════════════════════════════════════════ *@
|
||||
else if (_tab == "subscription")
|
||||
{
|
||||
@* ── Current Plan ── *@
|
||||
<div class="admin-panel" style="border:2px solid var(--admin-orange-primary);">
|
||||
<div class="admin-panel__header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="crown" style="color:var(--admin-orange-primary);"></i>
|
||||
Gói hiện tại
|
||||
</h3>
|
||||
<div style="padding:4px 12px;border-radius:20px;background:var(--admin-orange-primary);color:white;font-size:11px;font-weight:700;">
|
||||
@_currentPlanName
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;">
|
||||
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
|
||||
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageShops/@(FormatLimit(_limitShops))</div>
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);">Cửa hàng</div>
|
||||
</div>
|
||||
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
|
||||
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageStaff/@(FormatLimit(_limitStaff))</div>
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);">Nhân viên/shop</div>
|
||||
</div>
|
||||
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
|
||||
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageVerticals/@(FormatLimit(_limitVerticals))</div>
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);">Ngành nghề</div>
|
||||
</div>
|
||||
<div style="padding:12px;background:var(--admin-bg-interactive);border-radius:10px;text-align:center;">
|
||||
<div style="font-size:22px;font-weight:700;color:var(--admin-text-primary);">@_usageProducts/@(FormatLimit(_limitProducts))</div>
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);">Sản phẩm</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Plan Comparison ── *@
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;">
|
||||
@foreach (var plan in _plans)
|
||||
{
|
||||
var isCurrent = plan.Id == _currentPlanId;
|
||||
<div class="admin-panel" style="@(isCurrent ? "border:2px solid var(--admin-orange-primary);" : "") display:flex;flex-direction:column;">
|
||||
<div class="admin-panel__header" style="text-align:center;">
|
||||
@if (plan.Id == 2)
|
||||
{
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--admin-orange-primary);margin-bottom:4px;">Phổ biến nhất</div>
|
||||
}
|
||||
<h3 style="font-size:16px;font-weight:700;margin:0;">@plan.Name</h3>
|
||||
<div style="font-size:24px;font-weight:800;color:var(--admin-orange-primary);margin:8px 0;">
|
||||
@(plan.Price == 0 ? "Miễn phí" : $"{plan.Price:N0}₫")
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--admin-text-tertiary);">@(plan.Price > 0 ? "/tháng" : "mãi mãi")</div>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="flex:1;display:flex;flex-direction:column;gap:8px;font-size:12px;">
|
||||
@foreach (var f in plan.Features)
|
||||
{
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<i data-lucide="@(f.Included ? "check" : "x")" style="width:14px;height:14px;color:@(f.Included ? "#22C55E" : "var(--admin-text-tertiary)");flex-shrink:0;"></i>
|
||||
<span style="color:@(f.Included ? "var(--admin-text-primary)" : "var(--admin-text-tertiary)");">@f.Label</span>
|
||||
</div>
|
||||
}
|
||||
<div style="margin-top:auto;padding-top:12px;">
|
||||
@if (isCurrent)
|
||||
{
|
||||
<button class="admin-btn-secondary" style="width:100%;justify-content:center;font-size:12px;" disabled>Gói hiện tại</button>
|
||||
}
|
||||
else if (plan.Id > _currentPlanId)
|
||||
{
|
||||
<button class="admin-btn-primary" style="width:100%;justify-content:center;font-size:12px;" @onclick="() => UpgradePlan(plan.Id)">
|
||||
Nâng cấp
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ════════════════════════════════════════════════════════════════════
|
||||
TAB: THÔNG BÁO
|
||||
════════════════════════════════════════════════════════════════════ *@
|
||||
else if (_tab == "notifications")
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="bell" style="color:#F59E0B;"></i>
|
||||
Cài đặt thông báo
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
|
||||
@foreach (var notif in _notifSettings)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div style="display:flex;flex-direction:column;">
|
||||
<span style="font-size:13px;font-weight:600;">@notif.Label</span>
|
||||
<span style="font-size:11px;color:var(--admin-text-tertiary);">@notif.Desc</span>
|
||||
</div>
|
||||
<MudSwitch T="bool" Value="notif.Enabled" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ════════════════════════════════════════════════════════════════════
|
||||
TAB: HỆ THỐNG
|
||||
════════════════════════════════════════════════════════════════════ *@
|
||||
else if (_tab == "general")
|
||||
{
|
||||
@* ── System Info ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
@@ -68,7 +499,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Service Health ── *@
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
@@ -91,150 +521,72 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_tab == "account")
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="user" style="color:var(--admin-orange-primary);"></i>
|
||||
Thông tin tài khoản
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Email</label>
|
||||
<input class="admin-form-input" value="@(AuthState.UserEmail ?? "—")" readonly />
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Vai trò</label>
|
||||
<input class="admin-form-input" value="Owner" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-form-group">
|
||||
<label class="admin-form-label">Trạng thái</label>
|
||||
<div class="admin-status-badge admin-status-badge--online" style="width:fit-content;">
|
||||
<span class="admin-status-badge__dot"></span>
|
||||
Đã xác thực
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_tab == "notifications")
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="bell" style="color:#F59E0B;"></i>
|
||||
Cài đặt thông báo
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:12px;">
|
||||
@foreach (var notif in _notifSettings)
|
||||
{
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div style="display:flex;flex-direction:column;">
|
||||
<span style="font-size:13px;font-weight:600;">@notif.Label</span>
|
||||
<span style="font-size:11px;color:var(--admin-text-tertiary);">@notif.Desc</span>
|
||||
</div>
|
||||
<MudSwitch T="bool" Value="notif.Enabled" Color="Color.Primary" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_tab == "security")
|
||||
{
|
||||
<div class="admin-panel">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title">
|
||||
<i data-lucide="lock" style="color:#EF4444;"></i>
|
||||
Bảo mật
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body" style="display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div>
|
||||
<span style="font-size:13px;font-weight:600;">Đổi mật khẩu</span>
|
||||
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Thay đổi mật khẩu đăng nhập</p>
|
||||
</div>
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;">Đổi mật khẩu</button>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div>
|
||||
<span style="font-size:13px;font-weight:600;">Xác thực 2 bước (2FA)</span>
|
||||
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Bảo mật tài khoản bằng mã OTP</p>
|
||||
</div>
|
||||
<MudSwitch T="bool" Value="false" Color="Color.Primary" />
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:var(--admin-bg-interactive);border-radius:10px;">
|
||||
<div>
|
||||
<span style="font-size:13px;font-weight:600;">Phiên đăng nhập</span>
|
||||
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Quản lý các phiên đang hoạt động</p>
|
||||
</div>
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;">Xem phiên</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ─── DANGER ZONE ─── *@
|
||||
<div class="admin-panel" style="border:1px solid rgba(239,68,68,0.3);">
|
||||
<div class="admin-panel__header">
|
||||
<h3 class="admin-panel__title" style="color:#EF4444;">
|
||||
<i data-lucide="alert-triangle" style="color:#EF4444;"></i>
|
||||
Vùng nguy hiểm
|
||||
</h3>
|
||||
</div>
|
||||
<div class="admin-panel__body">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<span style="font-size:13px;font-weight:600;">Xóa tài khoản</span>
|
||||
<p style="font-size:11px;color:var(--admin-text-tertiary);margin:0;">Xóa vĩnh viễn tài khoản và toàn bộ dữ liệu</p>
|
||||
</div>
|
||||
<button class="admin-btn-secondary" style="font-size:12px;padding:6px 12px;color:#EF4444;border-color:rgba(239,68,68,0.3);">Xóa tài khoản</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string _tab = "general";
|
||||
private string _tab = "account";
|
||||
private int _shopCount = 0;
|
||||
private bool _loadingAccount = true;
|
||||
private bool _saving = false;
|
||||
|
||||
// ═══ Account/Profile state ═══
|
||||
private string _firstName = "";
|
||||
private string _lastName = "";
|
||||
private string _bio = "";
|
||||
private string _timezone = "Asia/Ho_Chi_Minh";
|
||||
private bool _editingProfile = false;
|
||||
private string? _profileMsg;
|
||||
private bool _profileMsgOk;
|
||||
|
||||
// ═══ Merchant state ═══
|
||||
private string _businessName = "";
|
||||
private string _merchantType = "Individual";
|
||||
private string _taxId = "";
|
||||
private string _verificationStatus = "Unverified";
|
||||
private string _merchantCreatedAt = "—";
|
||||
private bool _editingMerchant = false;
|
||||
private string? _merchantMsg;
|
||||
private bool _merchantMsgOk;
|
||||
|
||||
// ═══ Security state ═══
|
||||
private string _currentPassword = "";
|
||||
private string _newPassword = "";
|
||||
private string _confirmPassword = "";
|
||||
private string? _pwMsg;
|
||||
private bool _pwMsgOk;
|
||||
private bool _twoFactorEnabled = false;
|
||||
private bool _show2FASetup = false;
|
||||
private bool _showDisable2FA = false;
|
||||
private string? _qrCodeBase64;
|
||||
private string? _manualKey;
|
||||
private string _totpCode = "";
|
||||
private string? _tfaMsg;
|
||||
private bool _tfaMsgOk;
|
||||
private bool _linkedGoogle = false;
|
||||
private bool _linkedFacebook = false;
|
||||
|
||||
// ═══ Subscription state ═══
|
||||
private int _currentPlanId = 0;
|
||||
private string _currentPlanName = "Starter";
|
||||
private int _usageShops = 0, _limitShops = 1;
|
||||
private int _usageStaff = 0, _limitStaff = 3;
|
||||
private int _usageVerticals = 0, _limitVerticals = 1;
|
||||
private int _usageProducts = 0, _limitProducts = 50;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Format limit value, showing ∞ for unlimited (>= 9999).
|
||||
/// VI: Định dạng giới hạn, hiển thị ∞ cho không giới hạn (>= 9999).
|
||||
/// </summary>
|
||||
private string FormatLimit(int limit) => limit >= 9999 ? "∞" : limit.ToString();
|
||||
|
||||
// EN: Service list matching actual deployed Docker containers
|
||||
// VI: Danh sách dịch vụ khớp với Docker containers thực tế
|
||||
private readonly (string Name, string Icon)[] _services = new[]
|
||||
{
|
||||
("IAM Service", "shield"),
|
||||
("Merchant Service", "store"),
|
||||
("Catalog Service", "package"),
|
||||
("Order Service", "shopping-bag"),
|
||||
("Inventory Service", "warehouse"),
|
||||
("Wallet Service", "wallet"),
|
||||
("Membership Service", "users"),
|
||||
("Promotion Service", "tag"),
|
||||
("Booking Service", "calendar"),
|
||||
("F&B Engine", "utensils"),
|
||||
("Storage Service", "hard-drive"),
|
||||
("IAM Service", "shield"), ("Merchant Service", "store"), ("Catalog Service", "package"),
|
||||
("Order Service", "shopping-bag"), ("Inventory Service", "warehouse"), ("Wallet Service", "wallet"),
|
||||
("Membership Service", "users"), ("Promotion Service", "tag"), ("Booking Service", "calendar"),
|
||||
("F&B Engine", "utensils"), ("Storage Service", "hard-drive"),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
try
|
||||
{
|
||||
var shops = await DataService.GetShopsAsync();
|
||||
_shopCount = shops.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_shopCount = 0;
|
||||
Console.Error.WriteLine($"[AdminSettings] Error loading shop count: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private record NotifSetting(string Label, string Desc, bool Enabled);
|
||||
private readonly NotifSetting[] _notifSettings = new[]
|
||||
{
|
||||
@@ -245,4 +597,270 @@
|
||||
new NotifSetting("Doanh thu bất thường", "Cảnh báo doanh thu", true),
|
||||
new NotifSetting("Email hàng tuần", "Báo cáo doanh thu hàng tuần", true),
|
||||
};
|
||||
|
||||
// ═══ Subscription Plans (static) ═══
|
||||
private record PlanFeature(string Label, bool Included);
|
||||
private record PlanInfo(int Id, string Name, decimal Price, PlanFeature[] Features);
|
||||
private readonly PlanInfo[] _plans = new[]
|
||||
{
|
||||
new PlanInfo(0, "Starter", 0, new[]
|
||||
{
|
||||
new PlanFeature("1 cửa hàng", true), new PlanFeature("3 nhân viên/shop", true),
|
||||
new PlanFeature("50 sản phẩm", true), new PlanFeature("POS cơ bản", true),
|
||||
new PlanFeature("1 ngành nghề", true), new PlanFeature("Báo cáo cơ bản", true),
|
||||
new PlanFeature("Kitchen Display", false), new PlanFeature("Booking", false),
|
||||
new PlanFeature("Marketing CRM", false),
|
||||
}),
|
||||
new PlanInfo(1, "Growth", 299000, new[]
|
||||
{
|
||||
new PlanFeature("3 cửa hàng", true), new PlanFeature("10 nhân viên/shop", true),
|
||||
new PlanFeature("500 sản phẩm", true), new PlanFeature("POS đa ngành", true),
|
||||
new PlanFeature("2 ngành nghề", true), new PlanFeature("Báo cáo tiêu chuẩn", true),
|
||||
new PlanFeature("Kitchen Display", true), new PlanFeature("Booking", true),
|
||||
new PlanFeature("Marketing CRM", false),
|
||||
}),
|
||||
new PlanInfo(2, "Pro", 799000, new[]
|
||||
{
|
||||
new PlanFeature("10 cửa hàng", true), new PlanFeature("50 nhân viên/shop", true),
|
||||
new PlanFeature("Sản phẩm không giới hạn", true), new PlanFeature("POS đa ngành", true),
|
||||
new PlanFeature("4 ngành nghề", true), new PlanFeature("Báo cáo nâng cao", true),
|
||||
new PlanFeature("Kitchen Display", true), new PlanFeature("Booking", true),
|
||||
new PlanFeature("Marketing CRM + Chatbot", true),
|
||||
}),
|
||||
new PlanInfo(3, "Enterprise", 0, new[]
|
||||
{
|
||||
new PlanFeature("Không giới hạn cửa hàng", true), new PlanFeature("Không giới hạn nhân viên", true),
|
||||
new PlanFeature("Không giới hạn sản phẩm", true), new PlanFeature("Tất cả ngành nghề", true),
|
||||
new PlanFeature("White-label", true), new PlanFeature("API Access", true),
|
||||
new PlanFeature("SLA & Support ưu tiên", true), new PlanFeature("Báo cáo tùy chỉnh", true),
|
||||
new PlanFeature("Mọi tính năng", true),
|
||||
}),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
await LoadAccountData();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { }
|
||||
}
|
||||
|
||||
private async Task LoadAccountData()
|
||||
{
|
||||
_loadingAccount = true;
|
||||
try
|
||||
{
|
||||
var shops = await DataService.GetShopsAsync();
|
||||
_shopCount = shops.Count;
|
||||
_usageShops = shops.Count;
|
||||
}
|
||||
catch { _shopCount = 0; }
|
||||
|
||||
// EN: Load user profile from BFF
|
||||
// VI: Tải profile user từ BFF
|
||||
try
|
||||
{
|
||||
var me = await DataService.GetFromApiAsync<AccountMeDto>("api/bff/account/me");
|
||||
if (me != null)
|
||||
{
|
||||
_firstName = me.FirstName ?? "";
|
||||
_lastName = me.LastName ?? "";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var profile = await DataService.GetFromApiAsync<ProfileDto>("api/bff/account/profile");
|
||||
if (profile != null)
|
||||
{
|
||||
_bio = profile.Bio ?? "";
|
||||
_timezone = profile.Timezone ?? "Asia/Ho_Chi_Minh";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// EN: Load merchant profile
|
||||
// VI: Tải thông tin merchant
|
||||
try
|
||||
{
|
||||
var merchant = await DataService.GetFromApiAsync<MerchantDto>("api/bff/account/merchant");
|
||||
if (merchant != null)
|
||||
{
|
||||
_businessName = merchant.BusinessName ?? "";
|
||||
_merchantType = merchant.Type ?? "Individual";
|
||||
_taxId = merchant.BusinessInfo?.TaxId ?? "";
|
||||
_verificationStatus = merchant.VerificationStatus ?? "Unverified";
|
||||
_merchantCreatedAt = merchant.CreatedAt?.ToString("dd/MM/yyyy") ?? "—";
|
||||
|
||||
// EN: Set subscription plan from merchant data
|
||||
// VI: Thiết lập gói đăng ký từ dữ liệu merchant
|
||||
_currentPlanId = merchant.SubscriptionPlanId;
|
||||
_currentPlanName = _currentPlanId >= 0 && _currentPlanId < _plans.Length
|
||||
? _plans[_currentPlanId].Name
|
||||
: "Starter";
|
||||
(_limitShops, _limitStaff, _limitVerticals, _limitProducts) = _currentPlanId switch
|
||||
{
|
||||
1 => (3, 10, 2, 500),
|
||||
2 => (10, 50, 4, 9999),
|
||||
3 => (9999, 9999, 9999, 9999),
|
||||
_ => (1, 3, 1, 50),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// EN: Load linked accounts
|
||||
// VI: Tải tài khoản liên kết
|
||||
try
|
||||
{
|
||||
var linked = await DataService.GetFromApiAsync<LinkedAccountsDto>("api/bff/account/linked-accounts");
|
||||
if (linked?.LinkedProviders != null)
|
||||
{
|
||||
_linkedGoogle = linked.LinkedProviders.Any(p => p.Provider == "Google");
|
||||
_linkedFacebook = linked.LinkedProviders.Any(p => p.Provider == "Facebook");
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
_loadingAccount = false;
|
||||
}
|
||||
|
||||
private void SwitchTab(string tab) { _tab = tab; _profileMsg = null; _merchantMsg = null; _pwMsg = null; _tfaMsg = null; }
|
||||
|
||||
// ═══ Profile Edit ═══
|
||||
private string _origFirstName = "", _origLastName = "", _origBio = "", _origTimezone = "";
|
||||
private void StartEditProfile()
|
||||
{
|
||||
_origFirstName = _firstName; _origLastName = _lastName; _origBio = _bio; _origTimezone = _timezone;
|
||||
_editingProfile = true; _profileMsg = null;
|
||||
}
|
||||
private void CancelEditProfile()
|
||||
{
|
||||
_firstName = _origFirstName; _lastName = _origLastName; _bio = _origBio; _timezone = _origTimezone;
|
||||
_editingProfile = false; _profileMsg = null;
|
||||
}
|
||||
|
||||
private async Task SaveProfile()
|
||||
{
|
||||
_saving = true; _profileMsg = null;
|
||||
try
|
||||
{
|
||||
var ok1 = await DataService.PutAsync("api/bff/account/me", new { firstName = _firstName, lastName = _lastName });
|
||||
var ok2 = await DataService.PutAsync("api/bff/account/profile", new { bio = _bio, timezone = _timezone });
|
||||
_profileMsg = ok1 || ok2 ? "Cập nhật thành công!" : "Không thể cập nhật";
|
||||
_profileMsgOk = ok1 || ok2;
|
||||
if (_profileMsgOk) _editingProfile = false;
|
||||
}
|
||||
catch (Exception ex) { _profileMsg = ex.Message; _profileMsgOk = false; }
|
||||
finally { _saving = false; }
|
||||
}
|
||||
|
||||
private async Task SaveMerchant()
|
||||
{
|
||||
_saving = true; _merchantMsg = null;
|
||||
try
|
||||
{
|
||||
var ok = await DataService.PutAsync("api/bff/account/merchant", new { businessName = _businessName, taxId = _taxId });
|
||||
_merchantMsg = ok ? "Cập nhật doanh nghiệp thành công!" : "Không thể cập nhật";
|
||||
_merchantMsgOk = ok;
|
||||
if (ok) _editingMerchant = false;
|
||||
}
|
||||
catch (Exception ex) { _merchantMsg = ex.Message; _merchantMsgOk = false; }
|
||||
finally { _saving = false; }
|
||||
}
|
||||
|
||||
// ═══ Change Password ═══
|
||||
private async Task ChangePassword()
|
||||
{
|
||||
_pwMsg = null;
|
||||
if (string.IsNullOrEmpty(_currentPassword) || string.IsNullOrEmpty(_newPassword))
|
||||
{ _pwMsg = "Vui lòng nhập đầy đủ"; _pwMsgOk = false; return; }
|
||||
if (_newPassword != _confirmPassword)
|
||||
{ _pwMsg = "Mật khẩu xác nhận không khớp"; _pwMsgOk = false; return; }
|
||||
if (_newPassword.Length < 8)
|
||||
{ _pwMsg = "Mật khẩu mới phải ít nhất 8 ký tự"; _pwMsgOk = false; return; }
|
||||
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var ok = await DataService.PostAsync("api/bff/account/change-password",
|
||||
new { currentPassword = _currentPassword, newPassword = _newPassword });
|
||||
_pwMsg = ok ? "Đổi mật khẩu thành công!" : "Mật khẩu hiện tại không đúng";
|
||||
_pwMsgOk = ok;
|
||||
if (ok) { _currentPassword = ""; _newPassword = ""; _confirmPassword = ""; }
|
||||
}
|
||||
catch (Exception ex) { _pwMsg = ex.Message; _pwMsgOk = false; }
|
||||
finally { _saving = false; }
|
||||
}
|
||||
|
||||
// ═══ 2FA ═══
|
||||
private async Task StartEnable2FA()
|
||||
{
|
||||
_saving = true; _tfaMsg = null;
|
||||
try
|
||||
{
|
||||
var result = await DataService.PostAndGetAsync<TwoFASetupDto>("api/bff/account/2fa/enable", new { });
|
||||
if (result != null)
|
||||
{
|
||||
_qrCodeBase64 = result.QrCodeBase64;
|
||||
_manualKey = result.ManualEntryKey;
|
||||
_show2FASetup = true;
|
||||
}
|
||||
else { _tfaMsg = "Không thể khởi tạo 2FA"; _tfaMsgOk = false; }
|
||||
}
|
||||
catch (Exception ex) { _tfaMsg = ex.Message; _tfaMsgOk = false; }
|
||||
finally { _saving = false; }
|
||||
}
|
||||
|
||||
private async Task Verify2FA()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_totpCode) || _totpCode.Length != 6)
|
||||
{ _tfaMsg = "Vui lòng nhập mã 6 số"; _tfaMsgOk = false; return; }
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var ok = await DataService.PostAsync("api/bff/account/2fa/verify", new { code = _totpCode });
|
||||
if (ok) { _twoFactorEnabled = true; _show2FASetup = false; _tfaMsg = "2FA đã được bật thành công!"; _tfaMsgOk = true; }
|
||||
else { _tfaMsg = "Mã OTP không hợp lệ"; _tfaMsgOk = false; }
|
||||
}
|
||||
catch (Exception ex) { _tfaMsg = ex.Message; _tfaMsgOk = false; }
|
||||
finally { _saving = false; _totpCode = ""; }
|
||||
}
|
||||
|
||||
private void StartDisable2FA() { _showDisable2FA = true; _tfaMsg = null; _totpCode = ""; }
|
||||
|
||||
private async Task Disable2FA()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_totpCode))
|
||||
{ _tfaMsg = "Vui lòng nhập mã OTP"; _tfaMsgOk = false; return; }
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var ok = await DataService.PostAsync("api/bff/account/2fa/disable", new { code = _totpCode });
|
||||
if (ok) { _twoFactorEnabled = false; _showDisable2FA = false; _tfaMsg = "2FA đã tắt"; _tfaMsgOk = true; }
|
||||
else { _tfaMsg = "Mã OTP không hợp lệ"; _tfaMsgOk = false; }
|
||||
}
|
||||
catch (Exception ex) { _tfaMsg = ex.Message; _tfaMsgOk = false; }
|
||||
finally { _saving = false; _totpCode = ""; }
|
||||
}
|
||||
|
||||
// ═══ Subscription ═══
|
||||
private void UpgradePlan(int planId)
|
||||
{
|
||||
// EN: In production, this would redirect to payment flow
|
||||
// VI: Trong production, sẽ redirect đến luồng thanh toán
|
||||
}
|
||||
|
||||
// ═══ DTOs ═══
|
||||
private record AccountMeDto { public string? Id { get; init; } public string? Email { get; init; } public string? FirstName { get; init; } public string? LastName { get; init; } public string? FullName { get; init; } public string[]? Roles { get; init; } }
|
||||
private record ProfileDto { public string? Bio { get; init; } public string? AvatarUrl { get; init; } public string? Timezone { get; init; } public string? Locale { get; init; } }
|
||||
private record MerchantDto { public string? BusinessName { get; init; } public string? Type { get; init; } public string? VerificationStatus { get; init; } public BusinessInfoDto? BusinessInfo { get; init; } public DateTime? CreatedAt { get; init; } public int SubscriptionPlanId { get; init; } }
|
||||
private record BusinessInfoDto { public string? TaxId { get; init; } }
|
||||
private record LinkedAccountsDto { public List<LinkedProviderDto>? LinkedProviders { get; init; } }
|
||||
private record LinkedProviderDto { public string? Provider { get; init; } }
|
||||
private record TwoFASetupDto { public string? QrCodeBase64 { get; init; } public string? ManualEntryKey { get; init; } public string[]? RecoveryCodes { get; init; } }
|
||||
}
|
||||
|
||||
@@ -76,6 +76,45 @@ public class PosDataService
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generic GET helper — deserializes single object from BFF (handles ApiResponse envelope).
|
||||
/// VI: Helper GET chung — deserialize object đơn từ BFF (xử lý ApiResponse envelope).
|
||||
/// </summary>
|
||||
public Task<T?> GetFromApiAsync<T>(string url) where T : class
|
||||
=> GetObjectFromApiAsync<T>(url);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generic PUT helper — sends JSON body to BFF endpoint.
|
||||
/// VI: Helper PUT chung — gửi JSON body đến BFF endpoint.
|
||||
/// </summary>
|
||||
public async Task<bool> PutAsync(string url, object body)
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PutAsJsonAsync(url, body, _writeOptions);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: POST and deserialize response — sends JSON body and returns deserialized result.
|
||||
/// VI: POST và deserialize response — gửi JSON body và trả về kết quả đã deserialize.
|
||||
/// </summary>
|
||||
public async Task<T?> PostAndGetAsync<T>(string url, object body) where T : class
|
||||
{
|
||||
AttachToken();
|
||||
var resp = await _http.PostAsJsonAsync(url, body, _writeOptions);
|
||||
if (!resp.IsSuccessStatusCode) return null;
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
return JsonSerializer.Deserialize<T>(data.GetRawText(), _jsonOptions);
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
return JsonSerializer.Deserialize<T>(json, _jsonOptions);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Robust list deserialization — handles plain arrays, PagedResult wrappers, and ApiResponse envelopes.
|
||||
/// VI: Deserialize list linh hoạt — xử lý array thuần, PagedResult wrapper, và ApiResponse envelope.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WebClientTpos.Server.Infrastructure;
|
||||
|
||||
namespace WebClientTpos.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Account controller — proxies to IAM and Merchant services for owner account management.
|
||||
/// VI: Controller tài khoản — proxy đến IAM và Merchant cho quản lý tài khoản chủ doanh nghiệp.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/bff/account")]
|
||||
public class AccountController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _iam;
|
||||
private readonly HttpClient _merchant;
|
||||
|
||||
public AccountController(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_iam = httpClientFactory.CreateClient("IamService");
|
||||
_merchant = httpClientFactory.CreateClient("MerchantService");
|
||||
}
|
||||
|
||||
// ═══ USER PROFILE ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current user info from IAM (id, email, firstName, lastName, roles).
|
||||
/// VI: Lấy thông tin user hiện tại từ IAM (id, email, firstName, lastName, vai trò).
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
var userId = ExtractUserIdFromJwt();
|
||||
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
|
||||
return await _iam.GetAsync($"/api/v1/users/{userId}").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get user profile (bio, avatar, timezone, locale, address, phone).
|
||||
/// VI: Lấy profile user (bio, avatar, timezone, ngôn ngữ, địa chỉ, SĐT).
|
||||
/// </summary>
|
||||
[HttpGet("profile")]
|
||||
public async Task<IActionResult> GetProfile()
|
||||
{
|
||||
var userId = ExtractUserIdFromJwt();
|
||||
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
|
||||
return await _iam.GetAsync($"/api/v1/users/{userId}/profile").ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update user profile (bio, timezone, locale, avatarUrl).
|
||||
/// VI: Cập nhật profile (bio, timezone, ngôn ngữ, avatarUrl).
|
||||
/// </summary>
|
||||
[HttpPut("profile")]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] JsonElement body)
|
||||
{
|
||||
var userId = ExtractUserIdFromJwt();
|
||||
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
|
||||
return await _iam.PutAsJsonAsync($"/api/v1/users/{userId}/profile", body).ProxyAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update user basic info (firstName, lastName).
|
||||
/// VI: Cập nhật tên user (firstName, lastName).
|
||||
/// </summary>
|
||||
[HttpPut("me")]
|
||||
public async Task<IActionResult> UpdateMe([FromBody] JsonElement body)
|
||||
{
|
||||
var userId = ExtractUserIdFromJwt();
|
||||
if (userId == null) return Unauthorized(new { success = false, message = "Not authenticated" });
|
||||
return await _iam.PutAsJsonAsync($"/api/v1/users/{userId}", body).ProxyAsync();
|
||||
}
|
||||
|
||||
// ═══ MERCHANT PROFILE ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current merchant profile.
|
||||
/// VI: Lấy thông tin merchant hiện tại.
|
||||
/// </summary>
|
||||
[HttpGet("merchant")]
|
||||
public Task<IActionResult> GetMerchant() =>
|
||||
_merchant.GetAsync("/api/v1/merchants/me").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update merchant profile (businessName, etc).
|
||||
/// VI: Cập nhật thông tin merchant (tên doanh nghiệp, ...).
|
||||
/// </summary>
|
||||
[HttpPut("merchant")]
|
||||
public Task<IActionResult> UpdateMerchant([FromBody] JsonElement body) =>
|
||||
_merchant.PutAsJsonAsync("/api/v1/merchants/me", body).ProxyAsync();
|
||||
|
||||
// ═══ SECURITY ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change password.
|
||||
/// VI: Đổi mật khẩu.
|
||||
/// </summary>
|
||||
[HttpPost("change-password")]
|
||||
public Task<IActionResult> ChangePassword([FromBody] JsonElement body) =>
|
||||
_iam.PostAsJsonAsync("/api/v1/auth/change-password", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Enable 2FA — returns QR code and recovery codes.
|
||||
/// VI: Bật 2FA — trả về QR code và mã khôi phục.
|
||||
/// </summary>
|
||||
[HttpPost("2fa/enable")]
|
||||
public Task<IActionResult> Enable2FA() =>
|
||||
_iam.PostAsync("/api/v1/auth/2fa/enable", null).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verify 2FA code to complete setup.
|
||||
/// VI: Xác thực mã 2FA để hoàn tất thiết lập.
|
||||
/// </summary>
|
||||
[HttpPost("2fa/verify")]
|
||||
public Task<IActionResult> Verify2FA([FromBody] JsonElement body) =>
|
||||
_iam.PostAsJsonAsync("/api/v1/auth/2fa/verify", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Disable 2FA.
|
||||
/// VI: Tắt 2FA.
|
||||
/// </summary>
|
||||
[HttpPost("2fa/disable")]
|
||||
public Task<IActionResult> Disable2FA([FromBody] JsonElement body) =>
|
||||
_iam.PostAsJsonAsync("/api/v1/auth/2fa/disable", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get linked OAuth accounts (Google, Facebook).
|
||||
/// VI: Lấy tài khoản liên kết (Google, Facebook).
|
||||
/// </summary>
|
||||
[HttpGet("linked-accounts")]
|
||||
public Task<IActionResult> GetLinkedAccounts() =>
|
||||
_iam.GetAsync("/api/v1/auth/linked-accounts").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Logout and revoke tokens.
|
||||
/// VI: Đăng xuất và thu hồi token.
|
||||
/// </summary>
|
||||
[HttpPost("logout")]
|
||||
public Task<IActionResult> Logout() =>
|
||||
_iam.PostAsync("/api/v1/auth/logout", null).ProxyAsync();
|
||||
|
||||
// ═══ SUBSCRIPTION ═══
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current subscription info.
|
||||
/// VI: Lấy thông tin gói dịch vụ hiện tại.
|
||||
/// </summary>
|
||||
[HttpGet("subscription")]
|
||||
public Task<IActionResult> GetSubscription() =>
|
||||
_merchant.GetAsync("/api/v1/subscriptions/me").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get available subscription plans.
|
||||
/// VI: Lấy danh sách gói dịch vụ.
|
||||
/// </summary>
|
||||
[HttpGet("subscription/plans")]
|
||||
public Task<IActionResult> GetPlans() =>
|
||||
_merchant.GetAsync("/api/v1/subscriptions/plans").ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subscribe or upgrade to a plan.
|
||||
/// VI: Đăng ký hoặc nâng cấp gói.
|
||||
/// </summary>
|
||||
[HttpPost("subscription/subscribe")]
|
||||
public Task<IActionResult> Subscribe([FromBody] JsonElement body) =>
|
||||
_merchant.PostAsJsonAsync("/api/v1/subscriptions/subscribe", body).ProxyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current usage vs limits.
|
||||
/// VI: Lấy mức sử dụng hiện tại so với giới hạn.
|
||||
/// </summary>
|
||||
[HttpGet("subscription/usage")]
|
||||
public Task<IActionResult> GetUsage() =>
|
||||
_merchant.GetAsync("/api/v1/subscriptions/usage").ProxyAsync();
|
||||
|
||||
// ═══ HELPERS ═══
|
||||
|
||||
private string? ExtractUserIdFromJwt()
|
||||
{
|
||||
var authHeader = Request.Headers["Authorization"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var token = authHeader["Bearer ".Length..];
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length < 2) return null;
|
||||
var payload = parts[1].Replace('-', '+').Replace('_', '/');
|
||||
switch (payload.Length % 4) { case 2: payload += "=="; break; case 3: payload += "="; break; }
|
||||
using var jwtDoc = JsonDocument.Parse(Convert.FromBase64String(payload));
|
||||
return jwtDoc.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ http:
|
||||
# EN: Merchant Service - Merchant & Shop Management
|
||||
# VI: Merchant Service - Quản lý Merchant & Shop
|
||||
merchant-service-router:
|
||||
rule: "PathPrefix(`/api/v1/merchants`) || PathPrefix(`/api/v1/shops`)"
|
||||
rule: "PathPrefix(`/api/v1/merchants`) || PathPrefix(`/api/v1/shops`) || PathPrefix(`/api/v1/subscriptions`)"
|
||||
service: merchant-service
|
||||
priority: 100
|
||||
middlewares:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
|
||||
<NoWarn>$(NoWarn);1591;CA2017;NU1902;NU1903;NU1904</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -34,23 +34,14 @@ public class UpdateUserCommandHandler : IRequestHandler<UpdateUserCommand, Updat
|
||||
throw new DomainException($"User with ID {request.UserId} not found.");
|
||||
}
|
||||
|
||||
// EN: Update user properties
|
||||
// VI: Cập nhật thuộc tính user
|
||||
if (!string.IsNullOrWhiteSpace(request.FirstName))
|
||||
{
|
||||
user.UpdateProfile(request.FirstName, user.LastName);
|
||||
}
|
||||
// EN: Update user properties — resolve final firstName/lastName before calling domain method once.
|
||||
// VI: Cập nhật thuộc tính user — xác định firstName/lastName cuối cùng trước khi gọi domain method.
|
||||
var newFirstName = !string.IsNullOrWhiteSpace(request.FirstName) ? request.FirstName : user.FirstName;
|
||||
var newLastName = !string.IsNullOrWhiteSpace(request.LastName) ? request.LastName : user.LastName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.LastName))
|
||||
if (!string.IsNullOrWhiteSpace(newFirstName) && !string.IsNullOrWhiteSpace(newLastName))
|
||||
{
|
||||
user.UpdateProfile(user.FirstName, request.LastName);
|
||||
}
|
||||
|
||||
// EN: If both are provided, update together
|
||||
// VI: Nếu cả hai được cung cấp, cập nhật cùng lúc
|
||||
if (!string.IsNullOrWhiteSpace(request.FirstName) && !string.IsNullOrWhiteSpace(request.LastName))
|
||||
{
|
||||
user.UpdateProfile(request.FirstName, request.LastName);
|
||||
user.UpdateProfile(newFirstName, newLastName);
|
||||
}
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using IamService.API.Application.Commands.Auth;
|
||||
|
||||
namespace IamService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ChangePasswordCommand.
|
||||
/// VI: Validator cho ChangePasswordCommand.
|
||||
/// </summary>
|
||||
public class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
|
||||
{
|
||||
public ChangePasswordCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.NotEmpty().WithMessage("User ID is required / User ID là bắt buộc");
|
||||
|
||||
RuleFor(x => x.CurrentPassword)
|
||||
.NotEmpty().WithMessage("Current password is required / Mật khẩu hiện tại là bắt buộc");
|
||||
|
||||
RuleFor(x => x.NewPassword)
|
||||
.NotEmpty().WithMessage("New password is required / Mật khẩu mới là bắt buộc")
|
||||
.MinimumLength(8).WithMessage("New password must be at least 8 characters / Mật khẩu mới phải có ít nhất 8 ký tự")
|
||||
.Matches("[A-Z]").WithMessage("New password must contain at least one uppercase letter / Mật khẩu mới phải chứa ít nhất một chữ hoa")
|
||||
.Matches("[a-z]").WithMessage("New password must contain at least one lowercase letter / Mật khẩu mới phải chứa ít nhất một chữ thường")
|
||||
.Matches("[0-9]").WithMessage("New password must contain at least one digit / Mật khẩu mới phải chứa ít nhất một chữ số")
|
||||
.Matches("[^a-zA-Z0-9]").WithMessage("New password must contain at least one special character / Mật khẩu mới phải chứa ít nhất một ký tự đặc biệt")
|
||||
.NotEqual(x => x.CurrentPassword).WithMessage("New password must be different from current password / Mật khẩu mới phải khác mật khẩu hiện tại");
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,12 @@ public static class AuthorizationExtensions
|
||||
// EN: Auditor - Read-only audit access
|
||||
// VI: Auditor - Quyền xem audit logs
|
||||
.AddPolicy("RequireAuditor", policy =>
|
||||
policy.RequireRole("SuperAdmin", "Admin", "Auditor"));
|
||||
policy.RequireRole("SuperAdmin", "Admin", "Auditor"))
|
||||
|
||||
// EN: OwnerOrAdmin - user accessing own resource or Admin/SuperAdmin
|
||||
// VI: OwnerOrAdmin - user truy cập resource của mình hoặc Admin/SuperAdmin
|
||||
.AddPolicy("OwnerOrAdmin", policy =>
|
||||
policy.Requirements.Add(new OwnerOrAdminRequirement()));
|
||||
|
||||
// EN: Register custom authorization handler for OwnerOrAdmin policy
|
||||
// VI: Đăng ký custom authorization handler cho policy OwnerOrAdmin
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
|
||||
<!-- EN: Email sending with MailKit / VI: Gửi email với MailKit -->
|
||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||
<PackageReference Include="MailKit" Version="4.10.0" />
|
||||
|
||||
<!-- EN: 2FA with TOTP / VI: 2FA với TOTP -->
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
|
||||
@@ -181,7 +181,21 @@ public class IamServiceContext : IdentityDbContext<ApplicationUser, ApplicationR
|
||||
|
||||
// EN: Configure Identity tables with custom names
|
||||
// VI: Cấu hình bảng Identity với tên tùy chỉnh
|
||||
modelBuilder.Entity<ApplicationUser>().ToTable("users");
|
||||
modelBuilder.Entity<ApplicationUser>(b =>
|
||||
{
|
||||
b.ToTable("users");
|
||||
b.Property<string>("_firstName").HasColumnName("first_name").HasMaxLength(100);
|
||||
b.Property<string>("_lastName").HasColumnName("last_name").HasMaxLength(100);
|
||||
b.Property<DateTime>("_createdAt").HasColumnName("created_at");
|
||||
b.Property<DateTime?>("_lastLoginAt").HasColumnName("last_login_at");
|
||||
b.Ignore(u => u.FirstName);
|
||||
b.Ignore(u => u.LastName);
|
||||
b.Ignore(u => u.FullName);
|
||||
b.Ignore(u => u.Status);
|
||||
b.Ignore(u => u.CreatedAt);
|
||||
b.Ignore(u => u.LastLoginAt);
|
||||
b.Ignore(u => u.DomainEvents);
|
||||
});
|
||||
modelBuilder.Entity<ApplicationRole>().ToTable("roles");
|
||||
modelBuilder.Entity<IdentityUserRole<Guid>>().ToTable("user_roles");
|
||||
modelBuilder.Entity<IdentityUserClaim<Guid>>().ToTable("user_claims");
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// EN: Command to subscribe or upgrade merchant's plan.
|
||||
// VI: Command để đăng ký hoặc nâng cấp gói của merchant.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Commands.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to subscribe or upgrade to a plan.
|
||||
/// VI: Command để đăng ký hoặc nâng cấp gói.
|
||||
/// </summary>
|
||||
/// <param name="PlanId">Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói</param>
|
||||
public record SubscribeCommand(int PlanId) : IRequest<SubscribeCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of subscribe command.
|
||||
/// VI: Kết quả của subscribe command.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the operation was successful / Thao tác có thành công không</param>
|
||||
/// <param name="Message">Result message / Thông điệp kết quả</param>
|
||||
/// <param name="PlanId">Subscribed plan ID / ID gói đã đăng ký</param>
|
||||
/// <param name="PlanName">Subscribed plan name / Tên gói đã đăng ký</param>
|
||||
public record SubscribeCommandResult(bool Success, string Message, int PlanId, string PlanName);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for SubscribeCommand.
|
||||
/// VI: Handler cho SubscribeCommand.
|
||||
/// </summary>
|
||||
public class SubscribeCommandHandler : IRequestHandler<SubscribeCommand, SubscribeCommandResult>
|
||||
{
|
||||
private readonly IMerchantRepository _merchantRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ILogger<SubscribeCommandHandler> _logger;
|
||||
|
||||
public SubscribeCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<SubscribeCommandHandler> logger)
|
||||
{
|
||||
_merchantRepository = merchantRepository;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SubscribeCommandResult> Handle(SubscribeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
|
||||
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||
{
|
||||
return new SubscribeCommandResult(false, "User not authenticated", -1, "");
|
||||
}
|
||||
|
||||
var merchant = await _merchantRepository.GetByUserIdAsync(userId, cancellationToken);
|
||||
if (merchant == null)
|
||||
{
|
||||
_logger.LogWarning("EN: Merchant not found for user {UserId} / VI: Không tìm thấy merchant cho user {UserId}", userId);
|
||||
return new SubscribeCommandResult(false, "Merchant not found / Không tìm thấy merchant", -1, "");
|
||||
}
|
||||
|
||||
// EN: Update subscription plan via domain behavior method.
|
||||
// VI: Cập nhật gói đăng ký qua domain behavior method.
|
||||
merchant.UpdateSubscriptionPlan(request.PlanId);
|
||||
_merchantRepository.Update(merchant);
|
||||
await _merchantRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
var planNames = new[] { "Starter", "Growth", "Pro", "Enterprise" };
|
||||
var planName = request.PlanId >= 0 && request.PlanId < planNames.Length ? planNames[request.PlanId] : "Unknown";
|
||||
|
||||
_logger.LogInformation("EN: Merchant {MerchantId} subscribed to {PlanName} / VI: Merchant {MerchantId} đã đăng ký {PlanName}",
|
||||
merchant.Id, planName);
|
||||
|
||||
return new SubscribeCommandResult(true, $"Subscribed to {planName} plan successfully", request.PlanId, planName);
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,18 @@ public record MerchantProfileDto
|
||||
public string Type { get; init; } = null!;
|
||||
public string Status { get; init; } = null!;
|
||||
public string VerificationStatus { get; init; } = null!;
|
||||
public BusinessInfoDto? BusinessInfo { get; init; }
|
||||
public SettlementConfigDto? SettlementConfig { get; init; }
|
||||
public BusinessInfoDto? BusinessInfo { get; set; }
|
||||
public SettlementConfigDto? SettlementConfig { get; set; }
|
||||
public DateTime? VerifiedAt { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
public int ShopCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subscription plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
|
||||
/// VI: ID gói đăng ký (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
|
||||
/// </summary>
|
||||
public int SubscriptionPlanId { get; init; }
|
||||
}
|
||||
|
||||
public record BusinessInfoDto
|
||||
@@ -48,7 +54,7 @@ public record BusinessInfoDto
|
||||
|
||||
public record SettlementConfigDto
|
||||
{
|
||||
public BankAccountDto? BankAccount { get; init; }
|
||||
public BankAccountDto? BankAccount { get; set; }
|
||||
public decimal CommissionRate { get; init; }
|
||||
public string SettlementCycle { get; init; } = null!;
|
||||
public bool AutoSettlement { get; init; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
using MerchantService.Domain.SeedWork;
|
||||
|
||||
namespace MerchantService.API.Application.Queries.Merchants;
|
||||
|
||||
@@ -50,25 +51,54 @@ public class GetMerchantProfileQueryHandler : IRequestHandler<GetMerchantProfile
|
||||
|
||||
private static MerchantProfileDto MapToDto(Merchant merchant, int shopCount)
|
||||
{
|
||||
return new MerchantProfileDto
|
||||
// EN: Use TypeId/StatusId/VerificationStatusId to resolve Enumeration names,
|
||||
// because navigation properties (_type, _status, _verificationStatus) are Ignored in EF config.
|
||||
// VI: Dùng TypeId/StatusId/VerificationStatusId để resolve tên Enumeration,
|
||||
// vì navigation properties (_type, _status, _verificationStatus) bị Ignore trong EF config.
|
||||
var typeName = Enumeration.FromValue<MerchantType>(merchant.TypeId)?.Name ?? "Unknown";
|
||||
var statusName = Enumeration.FromValue<MerchantStatus>(merchant.StatusId)?.Name ?? "Unknown";
|
||||
var verificationName = Enumeration.FromValue<VerificationStatus>(merchant.VerificationStatusId)?.Name ?? "Unknown";
|
||||
|
||||
var dto = new MerchantProfileDto
|
||||
{
|
||||
Id = merchant.Id,
|
||||
UserId = merchant.UserId,
|
||||
BusinessName = merchant.BusinessName,
|
||||
Type = merchant.Type.Name,
|
||||
Status = merchant.Status.Name,
|
||||
VerificationStatus = merchant.VerificationStatus.Name,
|
||||
BusinessInfo = new BusinessInfoDto
|
||||
Type = typeName,
|
||||
Status = statusName,
|
||||
VerificationStatus = verificationName,
|
||||
VerifiedAt = merchant.VerifiedAt,
|
||||
CreatedAt = merchant.CreatedAt,
|
||||
UpdatedAt = merchant.UpdatedAt,
|
||||
ShopCount = shopCount,
|
||||
SubscriptionPlanId = merchant.SubscriptionPlanId
|
||||
};
|
||||
|
||||
if (merchant.BusinessInfo != null)
|
||||
{
|
||||
dto.BusinessInfo = new BusinessInfoDto
|
||||
{
|
||||
TaxId = merchant.BusinessInfo.TaxId,
|
||||
BusinessLicenseNumber = merchant.BusinessInfo.BusinessLicenseNumber,
|
||||
CompanyRegistrationNumber = merchant.BusinessInfo.CompanyRegistrationNumber,
|
||||
EstablishedDate = merchant.BusinessInfo.EstablishedDate,
|
||||
IsComplete = merchant.BusinessInfo.IsCompleteForVerification
|
||||
},
|
||||
SettlementConfig = new SettlementConfigDto
|
||||
};
|
||||
}
|
||||
|
||||
if (merchant.SettlementConfig != null)
|
||||
{
|
||||
dto.SettlementConfig = new SettlementConfigDto
|
||||
{
|
||||
BankAccount = new BankAccountDto
|
||||
CommissionRate = merchant.SettlementConfig.CommissionRate,
|
||||
SettlementCycle = SettlementCycle.FromValue<SettlementCycle>(merchant.SettlementConfig.SettlementCycleId).Name,
|
||||
AutoSettlement = merchant.SettlementConfig.AutoSettlement,
|
||||
IsComplete = merchant.SettlementConfig.IsComplete
|
||||
};
|
||||
|
||||
if (merchant.SettlementConfig.BankAccount != null)
|
||||
{
|
||||
dto.SettlementConfig.BankAccount = new BankAccountDto
|
||||
{
|
||||
BankCode = merchant.SettlementConfig.BankAccount.BankCode,
|
||||
BankName = merchant.SettlementConfig.BankAccount.BankName,
|
||||
@@ -76,17 +106,11 @@ public class GetMerchantProfileQueryHandler : IRequestHandler<GetMerchantProfile
|
||||
? $"****{merchant.SettlementConfig.BankAccount.AccountNumber[^4..]}"
|
||||
: null,
|
||||
AccountHolderName = merchant.SettlementConfig.BankAccount.AccountHolderName
|
||||
},
|
||||
CommissionRate = merchant.SettlementConfig.CommissionRate,
|
||||
SettlementCycle = SettlementCycle.FromValue<SettlementCycle>(merchant.SettlementConfig.SettlementCycleId).Name,
|
||||
AutoSettlement = merchant.SettlementConfig.AutoSettlement,
|
||||
IsComplete = merchant.SettlementConfig.IsComplete
|
||||
},
|
||||
VerifiedAt = merchant.VerifiedAt,
|
||||
CreatedAt = merchant.CreatedAt,
|
||||
UpdatedAt = merchant.UpdatedAt,
|
||||
ShopCount = shopCount
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,13 +146,14 @@ public class GetMerchantByIdQueryHandler : IRequestHandler<GetMerchantByIdQuery,
|
||||
Id = merchant.Id,
|
||||
UserId = merchant.UserId,
|
||||
BusinessName = merchant.BusinessName,
|
||||
Type = merchant.Type.Name,
|
||||
Status = merchant.Status.Name,
|
||||
VerificationStatus = merchant.VerificationStatus.Name,
|
||||
Type = Enumeration.FromValue<MerchantType>(merchant.TypeId)?.Name ?? "Unknown",
|
||||
Status = Enumeration.FromValue<MerchantStatus>(merchant.StatusId)?.Name ?? "Unknown",
|
||||
VerificationStatus = Enumeration.FromValue<VerificationStatus>(merchant.VerificationStatusId)?.Name ?? "Unknown",
|
||||
VerifiedAt = merchant.VerifiedAt,
|
||||
CreatedAt = merchant.CreatedAt,
|
||||
UpdatedAt = merchant.UpdatedAt,
|
||||
ShopCount = shops.Count
|
||||
ShopCount = shops.Count,
|
||||
SubscriptionPlanId = merchant.SubscriptionPlanId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// EN: Subscription queries and DTOs.
|
||||
// VI: Queries và DTOs cho gói đăng ký.
|
||||
|
||||
using MediatR;
|
||||
|
||||
namespace MerchantService.API.Application.Queries.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get current merchant's subscription info.
|
||||
/// VI: Query để lấy thông tin gói đăng ký của merchant hiện tại.
|
||||
/// </summary>
|
||||
public record GetSubscriptionQuery : IRequest<SubscriptionDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all available subscription plans.
|
||||
/// VI: Query để lấy danh sách tất cả gói đăng ký.
|
||||
/// </summary>
|
||||
public record GetSubscriptionPlansQuery : IRequest<IReadOnlyList<SubscriptionPlanDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get current usage vs limits.
|
||||
/// VI: Query để lấy mức sử dụng hiện tại so với giới hạn.
|
||||
/// </summary>
|
||||
public record GetSubscriptionUsageQuery : IRequest<SubscriptionUsageDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current subscription DTO.
|
||||
/// VI: DTO gói đăng ký hiện tại.
|
||||
/// </summary>
|
||||
public record SubscriptionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
|
||||
/// VI: ID gói (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
|
||||
/// </summary>
|
||||
public int PlanId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Plan name.
|
||||
/// VI: Tên gói.
|
||||
/// </summary>
|
||||
public string PlanName { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Max number of shops allowed.
|
||||
/// VI: Số shop tối đa được phép.
|
||||
/// </summary>
|
||||
public int MaxShops { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Max number of staff per shop.
|
||||
/// VI: Số nhân viên tối đa mỗi shop.
|
||||
/// </summary>
|
||||
public int MaxStaffPerShop { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Max number of products.
|
||||
/// VI: Số sản phẩm tối đa.
|
||||
/// </summary>
|
||||
public int MaxProducts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether analytics feature is available.
|
||||
/// VI: Tính năng phân tích có khả dụng không.
|
||||
/// </summary>
|
||||
public bool HasAnalytics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Whether priority support is available.
|
||||
/// VI: Hỗ trợ ưu tiên có khả dụng không.
|
||||
/// </summary>
|
||||
public bool HasPrioritySupport { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subscription plan DTO.
|
||||
/// VI: DTO gói đăng ký.
|
||||
/// </summary>
|
||||
public record SubscriptionPlanDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = null!;
|
||||
public string Description { get; init; } = null!;
|
||||
public decimal MonthlyPrice { get; init; }
|
||||
public int MaxShops { get; init; }
|
||||
public int MaxStaffPerShop { get; init; }
|
||||
public int MaxProducts { get; init; }
|
||||
public bool HasAnalytics { get; init; }
|
||||
public bool HasPrioritySupport { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subscription usage DTO — current usage vs plan limits.
|
||||
/// VI: DTO mức sử dụng — sử dụng hiện tại so với giới hạn gói.
|
||||
/// </summary>
|
||||
public record SubscriptionUsageDto
|
||||
{
|
||||
public int PlanId { get; init; }
|
||||
public string PlanName { get; init; } = null!;
|
||||
public int ShopsUsed { get; init; }
|
||||
public int ShopsLimit { get; init; }
|
||||
public int StaffUsed { get; init; }
|
||||
public int StaffLimit { get; init; }
|
||||
public int ProductsUsed { get; init; }
|
||||
public int ProductsLimit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// EN: Handlers for subscription queries.
|
||||
// VI: Handlers cho các subscription queries.
|
||||
|
||||
using MediatR;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
|
||||
using MerchantService.Domain.AggregatesModel.ShopAggregate;
|
||||
|
||||
namespace MerchantService.API.Application.Queries.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Static subscription plan definitions.
|
||||
/// VI: Định nghĩa tĩnh các gói đăng ký.
|
||||
/// </summary>
|
||||
internal static class SubscriptionPlans
|
||||
{
|
||||
public static readonly IReadOnlyList<SubscriptionPlanDto> All = new List<SubscriptionPlanDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 0, Name = "Starter", Description = "Free plan for small businesses / Gói miễn phí cho doanh nghiệp nhỏ",
|
||||
MonthlyPrice = 0m, MaxShops = 1, MaxStaffPerShop = 3, MaxProducts = 50,
|
||||
HasAnalytics = false, HasPrioritySupport = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 1, Name = "Growth", Description = "For growing businesses / Cho doanh nghiệp đang phát triển",
|
||||
MonthlyPrice = 299_000m, MaxShops = 3, MaxStaffPerShop = 10, MaxProducts = 500,
|
||||
HasAnalytics = true, HasPrioritySupport = false
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2, Name = "Pro", Description = "For professional businesses / Cho doanh nghiệp chuyên nghiệp",
|
||||
MonthlyPrice = 799_000m, MaxShops = 10, MaxStaffPerShop = 50, MaxProducts = 5000,
|
||||
HasAnalytics = true, HasPrioritySupport = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3, Name = "Enterprise", Description = "For large enterprises / Cho doanh nghiệp lớn",
|
||||
MonthlyPrice = 1_999_000m, MaxShops = 999, MaxStaffPerShop = 999, MaxProducts = 99999,
|
||||
HasAnalytics = true, HasPrioritySupport = true
|
||||
}
|
||||
};
|
||||
|
||||
public static SubscriptionPlanDto GetById(int planId) =>
|
||||
All.FirstOrDefault(p => p.Id == planId) ?? All[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSubscriptionQuery — returns current merchant's subscription info.
|
||||
/// VI: Handler cho GetSubscriptionQuery — trả về thông tin gói đăng ký của merchant hiện tại.
|
||||
/// </summary>
|
||||
public class GetSubscriptionQueryHandler : IRequestHandler<GetSubscriptionQuery, SubscriptionDto?>
|
||||
{
|
||||
private readonly IMerchantRepository _merchantRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public GetSubscriptionQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_merchantRepository = merchantRepository;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async Task<SubscriptionDto?> Handle(GetSubscriptionQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = ExtractUserId();
|
||||
if (userId == null) return null;
|
||||
|
||||
var merchant = await _merchantRepository.GetByUserIdAsync(userId.Value, cancellationToken);
|
||||
if (merchant == null) return null;
|
||||
|
||||
var plan = SubscriptionPlans.GetById(merchant.SubscriptionPlanId);
|
||||
|
||||
return new SubscriptionDto
|
||||
{
|
||||
PlanId = plan.Id,
|
||||
PlanName = plan.Name,
|
||||
MaxShops = plan.MaxShops,
|
||||
MaxStaffPerShop = plan.MaxStaffPerShop,
|
||||
MaxProducts = plan.MaxProducts,
|
||||
HasAnalytics = plan.HasAnalytics,
|
||||
HasPrioritySupport = plan.HasPrioritySupport
|
||||
};
|
||||
}
|
||||
|
||||
private Guid? ExtractUserId()
|
||||
{
|
||||
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
|
||||
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||
return null;
|
||||
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSubscriptionPlansQuery — returns all available plans.
|
||||
/// VI: Handler cho GetSubscriptionPlansQuery — trả về tất cả các gói có sẵn.
|
||||
/// </summary>
|
||||
public class GetSubscriptionPlansQueryHandler : IRequestHandler<GetSubscriptionPlansQuery, IReadOnlyList<SubscriptionPlanDto>>
|
||||
{
|
||||
public Task<IReadOnlyList<SubscriptionPlanDto>> Handle(GetSubscriptionPlansQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(SubscriptionPlans.All);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSubscriptionUsageQuery — returns current usage vs plan limits.
|
||||
/// VI: Handler cho GetSubscriptionUsageQuery — trả về mức sử dụng hiện tại so với giới hạn gói.
|
||||
/// </summary>
|
||||
public class GetSubscriptionUsageQueryHandler : IRequestHandler<GetSubscriptionUsageQuery, SubscriptionUsageDto?>
|
||||
{
|
||||
private readonly IMerchantRepository _merchantRepository;
|
||||
private readonly IShopRepository _shopRepository;
|
||||
private readonly IMerchantStaffRepository _staffRepository;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public GetSubscriptionUsageQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IShopRepository shopRepository,
|
||||
IMerchantStaffRepository staffRepository,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_merchantRepository = merchantRepository;
|
||||
_shopRepository = shopRepository;
|
||||
_staffRepository = staffRepository;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public async Task<SubscriptionUsageDto?> Handle(GetSubscriptionUsageQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = ExtractUserId();
|
||||
if (userId == null) return null;
|
||||
|
||||
var merchant = await _merchantRepository.GetByUserIdAsync(userId.Value, cancellationToken);
|
||||
if (merchant == null) return null;
|
||||
|
||||
var plan = SubscriptionPlans.GetById(merchant.SubscriptionPlanId);
|
||||
var shops = await _shopRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken);
|
||||
var staff = await _staffRepository.GetByMerchantIdAsync(merchant.Id, cancellationToken);
|
||||
|
||||
return new SubscriptionUsageDto
|
||||
{
|
||||
PlanId = plan.Id,
|
||||
PlanName = plan.Name,
|
||||
ShopsUsed = shops.Count,
|
||||
ShopsLimit = plan.MaxShops,
|
||||
StaffUsed = staff.Count,
|
||||
StaffLimit = plan.MaxStaffPerShop * shops.Count,
|
||||
// EN: Product count not tracked here — would need catalog service integration.
|
||||
// VI: Số sản phẩm không theo dõi ở đây — cần tích hợp catalog service.
|
||||
ProductsUsed = 0,
|
||||
ProductsLimit = plan.MaxProducts
|
||||
};
|
||||
}
|
||||
|
||||
private Guid? ExtractUserId()
|
||||
{
|
||||
var userIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value
|
||||
?? _httpContextAccessor.HttpContext?.User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
|
||||
return null;
|
||||
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// EN: Validators for Subscription commands.
|
||||
// VI: Validators cho các commands Subscription.
|
||||
|
||||
using FluentValidation;
|
||||
using MerchantService.API.Application.Commands.Subscriptions;
|
||||
|
||||
namespace MerchantService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for SubscribeCommand.
|
||||
/// VI: Validator cho SubscribeCommand.
|
||||
/// </summary>
|
||||
public class SubscribeCommandValidator : AbstractValidator<SubscribeCommand>
|
||||
{
|
||||
public SubscribeCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PlanId)
|
||||
.InclusiveBetween(0, 3)
|
||||
.WithMessage("Plan ID must be between 0 and 3 (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói phải từ 0 đến 3 (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// EN: Subscriptions Controller for subscription plan management.
|
||||
// VI: Controller Subscriptions để quản lý gói đăng ký.
|
||||
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MerchantService.API.Application.Commands.Subscriptions;
|
||||
using MerchantService.API.Application.Queries.Subscriptions;
|
||||
|
||||
namespace MerchantService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for subscription plan management.
|
||||
/// VI: Controller để quản lý gói đăng ký.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/subscriptions")]
|
||||
[Authorize]
|
||||
public class SubscriptionsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SubscriptionsController> _logger;
|
||||
|
||||
public SubscriptionsController(IMediator mediator, ILogger<SubscriptionsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current merchant's subscription info.
|
||||
/// VI: Lấy thông tin gói đăng ký của merchant hiện tại.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[ProducesResponseType(typeof(SubscriptionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSubscription(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSubscriptionQuery(), cancellationToken);
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = new { code = "SUBSCRIPTION_NOT_FOUND", message = "Merchant not found or not subscribed" } });
|
||||
}
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available subscription plans.
|
||||
/// VI: Lấy danh sách tất cả gói đăng ký có sẵn.
|
||||
/// </summary>
|
||||
[HttpGet("plans")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SubscriptionPlanDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetPlans(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSubscriptionPlansQuery(), cancellationToken);
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subscribe or upgrade to a plan.
|
||||
/// VI: Đăng ký hoặc nâng cấp gói.
|
||||
/// </summary>
|
||||
[HttpPost("subscribe")]
|
||||
[ProducesResponseType(typeof(SubscribeCommandResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Subscribe([FromBody] SubscribeCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(command, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(new { success = false, error = new { code = "SUBSCRIBE_FAILED", message = result.Message } });
|
||||
}
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
catch (Domain.Exceptions.DomainException ex)
|
||||
{
|
||||
return BadRequest(new { success = false, error = new { code = "INVALID_PLAN", message = ex.Message } });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get current usage vs plan limits.
|
||||
/// VI: Lấy mức sử dụng hiện tại so với giới hạn gói.
|
||||
/// </summary>
|
||||
[HttpGet("usage")]
|
||||
[ProducesResponseType(typeof(SubscriptionUsageDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetUsage(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSubscriptionUsageQuery(), cancellationToken);
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = new { code = "USAGE_NOT_FOUND", message = "Merchant not found" } });
|
||||
}
|
||||
return Ok(new { success = true, data = result });
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class Merchant : Entity, IAggregateRoot
|
||||
private BusinessInfo _businessInfo = null!;
|
||||
private SettlementConfig _settlementConfig = null!;
|
||||
private DateTime _createdAt;
|
||||
private int _subscriptionPlanId;
|
||||
private DateTime? _updatedAt;
|
||||
private bool _isDeleted;
|
||||
|
||||
@@ -110,6 +111,12 @@ public class Merchant : Entity, IAggregateRoot
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subscription plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
|
||||
/// VI: ID gói đăng ký (0=Starter, 1=Growth, 2=Pro, 3=Enterprise).
|
||||
/// </summary>
|
||||
public int SubscriptionPlanId => _subscriptionPlanId;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft delete flag.
|
||||
/// VI: Cờ xóa mềm.
|
||||
@@ -280,6 +287,20 @@ public class Merchant : Entity, IAggregateRoot
|
||||
AddDomainEvent(new MerchantBannedDomainEvent(this, reason));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update subscription plan.
|
||||
/// VI: Cập nhật gói đăng ký.
|
||||
/// </summary>
|
||||
/// <param name="planId">Plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise) / ID gói (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)</param>
|
||||
public void UpdateSubscriptionPlan(int planId)
|
||||
{
|
||||
if (planId < 0 || planId > 3)
|
||||
throw new DomainException("Invalid subscription plan ID. Must be 0-3.");
|
||||
|
||||
_subscriptionPlanId = planId;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Soft delete merchant.
|
||||
/// VI: Xóa mềm merchant.
|
||||
|
||||
@@ -115,6 +115,13 @@ public class MerchantEntityTypeConfiguration : IEntityTypeConfiguration<Merchant
|
||||
.HasColumnName("is_deleted")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
// EN: Subscription plan ID (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)
|
||||
// VI: ID gói đăng ký (0=Starter, 1=Growth, 2=Pro, 3=Enterprise)
|
||||
builder.Property<int>("_subscriptionPlanId")
|
||||
.HasColumnName("subscription_plan_id")
|
||||
.HasDefaultValue(0)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes
|
||||
// VI: Indexes
|
||||
builder.HasIndex("_userId").HasDatabaseName("ix_merchants_user_id");
|
||||
@@ -134,6 +141,7 @@ public class MerchantEntityTypeConfiguration : IEntityTypeConfiguration<Merchant
|
||||
builder.Ignore(m => m.CreatedAt);
|
||||
builder.Ignore(m => m.UpdatedAt);
|
||||
builder.Ignore(m => m.IsDeleted);
|
||||
builder.Ignore(m => m.SubscriptionPlanId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user