Implement member profile, email flows, and account governance

This commit is contained in:
Warren Chen 2026-04-17 03:46:32 +09:00
parent 5752d649e0
commit c9c0396ad2
68 changed files with 4749 additions and 102 deletions

View File

@ -82,12 +82,12 @@ SendEngine__WebhookSecret=change-me
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入
- 若設定環境變數,會優先使用環境變數(不寫入 appsettings
2) 執行 migrations不 Drop
3) 建立 rolesadmin, support
4) 建立 admin不存在才建立並加入 admin 角色
3) 建立 rolessuperuser, admin, support
4) 建立使用者(不存在才建立)並加入 `superuser` 角色
5) 寫入安裝鎖定DB flag: `system_flags` / `installed=true`
### 2) `installer add-admin`
用途:新增 superuser
### 2) `installer add-superuser`
用途:新增或提升 superuser
參數:
- `--admin-email <email>`
@ -96,10 +96,13 @@ SendEngine__WebhookSecret=change-me
流程:
1) 解析連線字串
2) 建立使用者並指派 admin 角色
2) 建立使用者並指派 `superuser` 角色
### 3) `installer reset-admin-password`
用途:重設指定 admin 密碼
相容性:
- 舊指令 `installer add-admin` 仍保留為 alias目前語意等同 `installer add-superuser`
### 3) `installer reset-superuser-password`
用途:重設指定 superuser 密碼
參數:
- `--admin-email <email>`
@ -109,6 +112,9 @@ SendEngine__WebhookSecret=change-me
1) 解析連線字串
2) 更新密碼(強制)
相容性:
- 舊指令 `installer reset-admin-password` 仍保留為 alias目前語意等同 `installer reset-superuser-password`
### 4) `installer migrate`
用途:只執行 migrations

View File

@ -12,6 +12,125 @@
- 擴充會員個人資料、地址簿與會員端訂閱管理能力,並同步定義可供其他服務使用的 profile scopes。
- 補齊帳號生命週期、審計紀錄、rate limit 與 MFA 的基礎治理規則。
## 實作進度2026-04-17
已完成:
- 建立 `user_profiles` / `user_addresses` 實體、DbContext 映射與 EF migration
- `users` 新增治理欄位:
- `last_login_at`
- `last_seen_at`
- `disabled_at`
- `disabled_by`
- 註冊與 external login 建立新帳號時,會同步建立空白 profile row
- 新增 current-user profile API
- `GET /user/profile`
- `POST /user/profile`
- `GET /user/addresses`
- `POST /user/addresses`
- `DELETE /user/addresses/{id}`
- `GET /user/subscriptions`
- `POST /user/subscriptions/{id}/unsubscribe`
- 新增會員端 Web UI
- `/profile`
- `/profile/addresses`
- `/profile/subscriptions`
- 新增會員端直接退訂流程,不需再透過 email token
- `profile:*` scopes 已註冊進 OpenIddict
- `profile:basic.read`
- `profile:basic.write`
- `profile:addresses.read`
- `profile:addresses.write`
- `profile:subscriptions.read`
- `profile:subscriptions.write`
- API 已接上 profile scope policies並補 service API 的 by-email 端點:
- `GET /user/profile/by-email`
- `POST /user/profile/by-email`
- `GET /user/addresses/by-email`
- `POST /user/addresses/by-email`
- `DELETE /user/addresses/by-email/{id}`
- `GET /user/subscriptions/by-email`
- `POST /user/subscriptions/by-email/{id}/unsubscribe`
- token resource 映射已將 `profile:*` 納入 member center audience
- `/admin/security` 已擴充 SMTP 設定欄位:
- relay host / port
- TLS / SSL
- timeout
- username / password
- sender name / sender email
- `/admin/security` 已新增 SMTP 測試信功能,可輸入測試收件 Email 送出測試信
- SMTP 設定已收斂到 DB flags密碼欄位留白時保留既有值
- 已建立共用帳號寄信服務,用於 Email 驗證信與密碼重設信
- 已抽出共用帳號 Email 模板服務:
- 驗證信模板
- 密碼重設信模板
- 註冊流程已改為寄送 Email 驗證信
- forgot password 已改為寄送重設信,不再直接回傳 reset token
- 已補 resend verification
- Web`POST /account/resendverification`
- API`POST /auth/email/resend`
- `/admin/security` 已補 `PublicBaseUrl`,作為驗證信與重設信連結的基準 URL
- 已補 audit log
- `account.verification_email_sent`
- `account.password_reset_email_sent`
- `account.email_verified`
- `account.password_reset_completed`
- 已啟用 Identity lockout 基礎策略:
- `MaxFailedAccessAttempts = 5`
- `DefaultLockoutTimeSpan = 15 分鐘`
- `AllowedForNewUsers = true`
- 已落地公開入口 rate limit
- Weblogin / register / forgot password / resend verification
- APIregister / forgot password / resend verification
- Newsletter APIpublic subscribe / unsubscribe token
- password grant login 已改為走 `SignInManager.CheckPasswordSignInAsync(..., lockoutOnFailure: true)`,與 Web login 共用 lockout 行為
- 已完成 `superuser` / `admin` 權限模型第一版落地:
- `Admin` policy 已擴為接受 `admin``superuser`
- 新增 `Superuser` policy
- installer `init` / `add-superuser` 會建立或提升 `superuser`
- 舊指令別名 `add-admin` / `reset-admin-password` 仍可用
- 已新增管理後台帳號治理頁:
- `/admin/accounts`
- 支援查詢帳號、查看 email verified / role / disabled / last login
- 只有 `superuser` 可授予或移除 `admin`
- 只有 `superuser` 可停用或啟用帳號
- 已補帳號治理規則:
- `superuser` 帳號不可在管理 UI 中被降權、停用、刪除或強制變更密碼
- disabled 中的 `admin` 可保留 `admin` role
- `superuser` 可在管理 UI 強制重設非 `superuser` 帳號密碼
- 強制重設密碼後會更新 security stamp 並撤銷既有 OpenIddict authorization / token
- 已補帳號治理與生命週期 audit log
- `account.registered`
- `account.external_login_linked`
- `account.password_changed`
- `account.role_changed`
- `account.disabled`
- `account.enabled`
- `system.security_settings_updated`
- `system.security_test_email_sent`
- 已補登入治理:
- Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at`
- disabled 帳號無法透過 Web login、external login、password grant 取得登入
- Web cookie 與 API authenticated request 會檢查 disabled 狀態
進行中:
- profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示
待續作:
- OAuth client usage 與 profile scopes 的最終授權矩陣仍偏靜態,尚未進到 resource registry / DB 驅動
- Auth resource registry 與 audience/scope 資料驅動化
- Email 驗證信 / 重設信的正式模板與文案優化
- rate limit 與 lockout 規則補齊:
- one-click unsubscribe token 申請
- 服務型 token flow 與人類登入 flow 的完整差異化治理
- 更細的風控觀測與後台設定化
- audit log 與 rate limit 尚未全面覆蓋所有規劃入口與治理事件
- `superuser` / `admin` 第一版已完成,後續待細化:
- 更細權限切分
- 是否需要更多治理角色
- 管理後台帳號治理功能補強:
- 更完整排序 / 篩選進一步細化
- 更細的操作確認與保護規則擴充
## 現況盤點
### 已存在
@ -44,23 +163,21 @@
- `send_engine_api`
### 部分實作
- 忘記密碼與 Email 驗證目前已有 token 產生與驗證邏輯,但尚未接入實際郵件寄送。
- `SendEngine__BaseUrl``SendEngine__WebhookSecret` 已有設定來源與 options binding但尚未進入可編輯的管理畫面。
- 系統目前已有 `admin` role policy但尚未明確拆出 `superuser` 與「由 superuser 指派 admin」的完整流程。
- `SendEngine__BaseUrl``SendEngine__WebhookSecret` 仍停留在設定來源與 options binding尚未進入可編輯的管理畫面。
- Auth 資源 / audience / scope registry 仍為靜態實作,尚未資料驅動化。
- 帳號治理後台、角色模型與 disabled account 規則已完成第一版,但仍缺進一步細化與保護規則。
- profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。
### 待補項
- SMTP 設定的持久化、管理 UI 與寄信服務抽象。
- Email 驗證信與忘記密碼信的正式寄送流程。
- 會員狀態與角色管理規則文件化,並同步到 UI/API/Installer 行為。
- 帳號管理畫面與權限控制。
- File Access 的 OAuth client usage、scope、audience 與 delegated token 發放流程尚未落地到程式。
- Token resource / audience 的設定方式目前仍偏硬編碼,尚未抽象成可擴充模型。
- 會員個人資料欄位、地址簿、個人資料 API 與 scope 尚未定義
- 會員端缺少「我的訂閱」頁面,無法在登入後集中管理已訂閱的電子報
- Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。
- rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。
## 功能規劃
### 1. 系統設定畫面
狀態:`部分完成`
#### 1.1 主要新增項
- 新增獨立的「系統設定」或「整合設定」畫面。
@ -75,6 +192,13 @@
- `SendEngine__BaseUrl`
- `SendEngine__WebhookSecret`
目前進度:
- 已完成 SMTP 與 token lifetime 設定 UI沿用 `/admin/security`
- 已完成 SMTP 測試信
- 已完成 `PublicBaseUrl`
- `SendEngine__BaseUrl` / `SendEngine__WebhookSecret` 尚未進管理畫面
- Auth 資源設定暫未實作,仍保留待 audience/scope 抽象化後處理
#### 1.2 可一併納入的既有設定
- 目前 `/admin/security` 已有的 token 時效設定可整併進同一個設定體系:
- Access token 分鐘數
@ -100,6 +224,7 @@
- 僅能覆寫、不可回顯的 secret 類設定
### 2. Email 驗證與忘記密碼
狀態:`核心完成,文案待續作`
#### 2.1 目標狀態
- 註冊後自動寄送 Email 驗證信。
@ -118,14 +243,10 @@
- `VerifyEmailResult`
#### 2.3 待補實作
- 郵件發送 abstraction例如 `IEmailSender`
- SMTP provider 實作與設定綁定。
- 註冊後觸發發送驗證信。
- 忘記密碼改為寄送信件,而不是在畫面或 API 直接回傳 token。
- Email 樣板:
- Email 樣板正式文案整理:
- 驗證信
- 忘記密碼信
- 重送驗證信入口。
- 更完整的產品提示與畫面細節整理
#### 2.4 安全與產品規則
- 忘記密碼 API 與 UI 都應避免暴露帳號是否存在。
@ -134,6 +255,7 @@
- 會員未驗證時是否允許登入與可操作範圍,需在實作時明確固定為單一規則,不保留模糊狀態。
### 3. 帳號分級與角色管理
狀態:`第一版完成,細化待續作`
#### 3.1 角色模型
- `superuser`
@ -158,14 +280,13 @@
- 企業帳號等級
#### 3.3 對現行實作的調整方向
- 現行 `admin` role 應重新定義:
- Installer 建立的高權限帳號應直接改為 `superuser`
- 管理後台中的角色治理操作應為 `superuser` 專屬。
- 需要新增帳號管理頁面,至少包含:
- 查詢帳號
- 檢視 Email 驗證狀態
- 指派或移除 `admin`
- 顯示是否為 installer 建立之 `superuser`
- 已完成:
- Installer 建立的高權限帳號改為 `superuser`
- 管理後台中的角色治理操作為 `superuser` 專屬
- 已新增 `/admin/accounts`,可查詢帳號、查看驗證狀態、指派或移除 `admin`、顯示 `superuser`
- 已補搜尋與篩選
- 待續作:
- 更細的角色切分與保護規則
#### 3.4 權限規則
- 只有 `superuser` 可變更角色。
@ -176,6 +297,7 @@
- 帳號治理權限
### 3.5 帳號生命週期
狀態:`部分完成`
- 帳號狀態至少包含:
- `active`
- `disabled`
@ -193,12 +315,21 @@
- `disabled_by`
- superuser 重設他人密碼、停用帳號、解除停用等治理規則,先記入規劃,細節待後續檢討。
目前進度:
- 已完成停用 / 啟用帳號
- 已完成 disabled 帳號不可登入與不可取得新 token
- 已完成 `last_login_at` / `last_seen_at` / `disabled_at` / `disabled_by`
- 已完成 superuser 重設非 superuser 帳號密碼,並強制讓既有 session / refresh token 失效
- superuser 本身的密碼治理仍限定走 installer
### 4. 第三方登入
狀態:`已完成本期範圍`
- 只支援 Google。
- 目前 Google external login 已存在,後續只補齊與整體權限模型的一致性。
- 若 Google 回傳 email 已驗證,可直接把會員標記為 `verified`;目前 `AccountProvisioningService` 已有依 external login provider 回填 `EmailConfirmed` 的基礎邏輯。
### 5. 會員基本資料與地址簿
狀態:`核心完成UI refinement 待續作`
#### 5.1 會員基本資料
- 會員可自行維護的基本資料欄位包含:
@ -270,6 +401,10 @@
- `nick_name` 為顯示用途,不作為唯一鍵
- `mobile_phone` 優先於 `landline_phone` 作為主要聯絡電話
目前進度:
- 上述欄位與資料模型、API、Web profile 編輯已完成第一版
- 欄位驗證與畫面體驗仍待整理
#### 5.2 地址簿 / 收貨地址清單
- 新增會員地址簿概念,支援一位會員維護多筆地址。
- 地址欄位包含:
@ -357,6 +492,12 @@
- `label` 可為自由文字,不先做 enum
- 地址至少保留一筆,因此最後一筆不可刪除
目前進度:
- 地址簿資料模型、API、Web UI 已完成第一版
- `最後一筆不可刪除` 已實作
- 預設地址切換與資料結構已完成
- 畫面提示與驗證細節仍待整理
#### 5.3 資料治理原則
- 會員基本資料與地址簿屬於會員中心主資料,可供其他服務查詢,但寫入權限需嚴格控管。
- 會員本人可編輯自己的基本資料與地址簿。
@ -364,6 +505,7 @@
- 若其他服務需要代會員寫入,必須有額外 scope 與審計規則。
### 6. 會員資料 API 與 Auth Scope 規範
狀態:`scope 已落地,資源抽象化待續作`
#### 6.1 規劃目的
- 讓其他服務可透過 API 取得會員中心的基本資料與地址資料。
@ -383,6 +525,11 @@
- `profile:subscriptions.write`
- 透過已登入會員介面取消訂閱或調整會員自己的訂閱狀態
目前進度:
- `profile:*` scopes 已註冊並接上 policy
- current-user 與 by-email service API 已完成第一版
- audience / resource registry 仍為靜態,不在本輪完成範圍內
#### 6.3 API 邊界建議
- 其他服務 API
- 目前規劃以 service API 為主
@ -401,6 +548,7 @@
- 建議保留 OIDC `profile` 作為基本 claims 用途,但業務資料改由自訂 scope 控制。
### 7. 會員端訂閱管理(我的訂閱)
狀態:`核心完成newsletter 補強待續作`
#### 7.1 目標
- 會員登入後,可集中查看自己目前已訂閱的電子報清單。
@ -420,6 +568,11 @@
- 直接取消訂閱
- 未來可擴充為偏好調整或重新訂閱
目前進度:
- 我的訂閱頁與 current-user 訂閱 API 已完成
- 會員登入後可直接退訂,不需再次 email token
- 進一步的 newsletter UI / 行為補強本輪刻意先跳過
#### 7.3 流程規則
- 已登入會員對自己的訂閱執行退訂時,不需 email token 二次確認。
- 系統仍需:
@ -444,6 +597,7 @@
- 兩種入口最終都回到同一組 subscription 資料。
### 8. 會員中心作為外部服務的 Token / Auth 中心
狀態:`部分完成audience/scope 抽象化待續作`
#### 8.1 共通模型
- Send Engine 與 File Access 本質上是同一套模型:
@ -494,7 +648,13 @@
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 `resources/audiences`
- delegated token 與一般 S2S token 共用同一套 token issuing abstraction。
目前進度:
- Send Engine 與 profile scopes 已有基礎 audience / resource 映射
- File Access 流程仍停留在文件與設計層
- resource registry / delegated token abstraction 尚未實作
### 9. 審計紀錄
狀態:`大致完成,少數治理事件待續作`
- 下列事件必須寫入 audit log
- 註冊
- Email 驗證成功
@ -512,7 +672,12 @@
- OAuth client secret 旋轉
- OAuth client secret 顯示一次、旋轉與治理能力視為既有基線;細節之後再檢討。
目前進度:
- 帳號寄信、驗證、重設密碼、修改密碼、註冊、external login 綁定、角色變更、帳號停用 / 啟用、profile、地址、會員端退訂、系統設定修改均已有實作
- OAuth client 建立與 secret 旋轉等治理細節仍待續作
### 10. Rate Limit 與防濫用
狀態:`部分完成`
- 下列入口必須有 rate limit
- login
- forgot password
@ -525,7 +690,13 @@
- 人類使用者登入
- service API token 申請
目前進度:
- 已完成 login / forgot password / resend verification / register / public subscribe / unsubscribe token 申請
- `one-click unsubscribe token` 申請仍待補
- 人類登入 flow 已有 lockoutservice API token flow 與更細觀測仍待續作
### 11. MFA 與非本期項目
狀態:`待續作`
- 在 Email 寄送能力完成後,可規劃 Email-based MFA / challenge 驗證。
- 其餘先明確列為未來可補項:
- consent / terms acceptance
@ -567,6 +738,16 @@
- MFA 預留
8. 文件、測試與 installer 同步調整
目前整體狀態:
- `1`:部分完成
- `2`:核心完成,文案待續作
- `3`:核心完成
- `4`:核心完成,但本輪不再補強 newsletter refinement
- `5`:第一版完成
- `6`:先跳過 audience / scope 抽象化
- `7`:大致完成,仍有少量補強
- `8`:部分完成
## 影響範圍
### Web

View File

@ -0,0 +1,34 @@
namespace MemberCenter.Api.Contracts;
public sealed record SaveProfileRequest(
string LastName,
string FirstName,
string? NickName,
string? MobilePhone,
string? LandlinePhone,
DateOnly? DateOfBirth,
string Gender,
string? CompanyName,
string? Department,
string? JobTitle,
string? CompanyPhone,
string? TaxId,
string? InvoiceTitle,
string? Remark);
public sealed record SaveAddressRequest(
Guid? Id,
string Label,
string RecipientName,
string RecipientPhone,
string CountryCode,
string? PostalCode,
string? StateRegion,
string? City,
string? District,
string AddressLine1,
string? AddressLine2,
string? CompanyName,
string Usage,
bool IsDefault,
string? AddressMetaJson);

View File

@ -259,6 +259,7 @@ public class AdminOAuthClientsController : ControllerBase
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
{
descriptor.Permissions.Add("scp:newsletter:events.write.global");
AddProfileScopePermissions(descriptor);
}
else if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
{
@ -268,6 +269,7 @@ public class AdminOAuthClientsController : ControllerBase
else
{
descriptor.Permissions.Add("scp:newsletter:events.write");
AddProfileScopePermissions(descriptor);
}
return;
}
@ -280,4 +282,14 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
descriptor.Permissions.Add("scp:openid");
}
private static void AddProfileScopePermissions(OpenIddictApplicationDescriptor descriptor)
{
descriptor.Permissions.Add("scp:profile:basic.read");
descriptor.Permissions.Add("scp:profile:basic.write");
descriptor.Permissions.Add("scp:profile:addresses.read");
descriptor.Permissions.Add("scp:profile:addresses.write");
descriptor.Permissions.Add("scp:profile:subscriptions.read");
descriptor.Permissions.Add("scp:profile:subscriptions.write");
}
}

View File

@ -1,9 +1,11 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MemberCenter.Api.Controllers;
@ -12,20 +14,27 @@ namespace MemberCenter.Api.Controllers;
public class AuthController : ControllerBase
{
private readonly IAccountProvisioningService _accountProvisioningService;
private readonly IAccountEmailService _accountEmailService;
private readonly IAuditLogWriter _auditLogWriter;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AuthController(
IAccountProvisioningService accountProvisioningService,
IAccountEmailService accountEmailService,
IAuditLogWriter auditLogWriter,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_accountProvisioningService = accountProvisioningService;
_accountEmailService = accountEmailService;
_auditLogWriter = auditLogWriter;
_userManager = userManager;
_signInManager = signInManager;
}
[HttpPost("register")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
@ -34,6 +43,12 @@ public class AuthController : ControllerBase
return BadRequest(result.Errors);
}
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is not null)
{
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
}
return Ok(new
{
id = result.UserId,
@ -44,6 +59,7 @@ public class AuthController : ControllerBase
}
[HttpPost("password/forgot")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
@ -52,8 +68,8 @@ public class AuthController : ControllerBase
return NoContent();
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
return Ok(new { token });
await _accountEmailService.SendPasswordResetEmailAsync(user.Id, GetBaseUrl());
return NoContent();
}
[HttpPost("password/reset")]
@ -71,6 +87,11 @@ public class AuthController : ControllerBase
return BadRequest(result.Errors.Select(e => e.Description));
}
await _auditLogWriter.WriteAsync("user", user.Id, "account.password_reset_completed", new
{
user_id = user.Id,
email = user.Email
});
return NoContent();
}
@ -89,9 +110,33 @@ public class AuthController : ControllerBase
return BadRequest(result.Errors.Select(e => e.Description));
}
await _auditLogWriter.WriteAsync("user", user.Id, "account.email_verified", new
{
user_id = user.Id,
email = user.Email
});
return Ok(new { status = "verified" });
}
[Authorize]
[HttpPost("email/resend")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
public async Task<IActionResult> ResendVerification()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
if (!user.EmailConfirmed)
{
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
}
return NoContent();
}
[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
@ -99,4 +144,6 @@ public class AuthController : ControllerBase
await _signInManager.SignOutAsync();
return NoContent();
}
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
}

View File

@ -1,7 +1,9 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenIddict.Abstractions;
namespace MemberCenter.Api.Controllers;
@ -18,6 +20,7 @@ public class NewsletterController : ControllerBase
}
[HttpPost("subscribe")]
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterSubscribe)]
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
{
var result = await _newsletterService.SubscribeAsync(request.ListId, request.Email, request.Preferences);
@ -76,6 +79,7 @@ public class NewsletterController : ControllerBase
}
[HttpPost("unsubscribe-token")]
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken)]
public async Task<IActionResult> IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request)
{
if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))

View File

@ -13,6 +13,7 @@ namespace MemberCenter.Api.Controllers;
[ApiController]
public class TokenController : ControllerBase
{
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
private readonly string _memberCenterAudience;
private readonly string _sendEngineAudience;
private readonly UserManager<ApplicationUser> _userManager;
@ -46,18 +47,22 @@ public class TokenController : ControllerBase
if (request.IsPasswordGrantType())
{
var user = await _userManager.FindByEmailAsync(request.Username ?? string.Empty);
if (user is null)
if (user is null || user.DisabledAt.HasValue)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var valid = await _userManager.CheckPasswordAsync(user, request.Password ?? string.Empty);
if (!valid)
var signInResult = await _signInManager.CheckPasswordSignInAsync(user, request.Password ?? string.Empty, true);
if (!signInResult.Succeeded)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = await _signInManager.CreateUserPrincipalAsync(user);
if (!string.IsNullOrWhiteSpace(user.SecurityStamp))
{
principal.SetClaim(SecurityStampClaimType, user.SecurityStamp);
}
var scopes = request.Scope.GetScopesOrDefault();
principal.SetScopes(scopes);
principal.SetResources(ResolveResources(scopes));
@ -67,6 +72,10 @@ public class TokenController : ControllerBase
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
user.LastLoginAt = DateTimeOffset.UtcNow;
user.LastSeenAt = user.LastLoginAt;
await _userManager.UpdateAsync(user);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
@ -157,7 +166,9 @@ public class TokenController : ControllerBase
resources.Add(_sendEngineAudience);
}
if (scopeSet.Any(scope => scope.StartsWith("newsletter:", StringComparison.Ordinal) && scope is not "newsletter:send.write" && scope is not "newsletter:send.read")
if (scopeSet.Any(scope =>
(scope.StartsWith("newsletter:", StringComparison.Ordinal) && scope is not "newsletter:send.write" && scope is not "newsletter:send.read")
|| scope.StartsWith("profile:", StringComparison.Ordinal))
|| scopeSet.Contains(OpenIddictConstants.Scopes.OpenId)
|| scopeSet.Contains(OpenIddictConstants.Scopes.Email)
|| scopeSet.Contains(OpenIddictConstants.Scopes.Profile))

View File

@ -1,3 +1,7 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Api.Extensions;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Profile;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@ -9,14 +13,21 @@ namespace MemberCenter.Api.Controllers;
[Route("user")]
public class UserController : ControllerBase
{
private readonly IProfileService _profileService;
private readonly INewsletterService _newsletterService;
private readonly UserManager<ApplicationUser> _userManager;
public UserController(UserManager<ApplicationUser> userManager)
public UserController(
IProfileService profileService,
INewsletterService newsletterService,
UserManager<ApplicationUser> userManager)
{
_profileService = profileService;
_newsletterService = newsletterService;
_userManager = userManager;
}
[Authorize]
[Authorize(Policy = "ProfileBasicRead")]
[HttpGet("profile")]
public async Task<IActionResult> Profile()
{
@ -26,12 +37,280 @@ public class UserController : ControllerBase
return Unauthorized();
}
var profile = await _profileService.GetProfileAsync(user.Id);
return Ok(new
{
id = user.Id,
email = user.Email,
email_verified = user.EmailConfirmed,
created_at = user.CreatedAt
created_at = user.CreatedAt,
profile
});
}
[Authorize(Policy = "ProfileBasicWrite")]
[HttpPost("profile")]
public async Task<IActionResult> SaveProfile([FromBody] SaveProfileRequest request)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
try
{
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
request.LastName,
request.FirstName,
request.NickName,
request.MobilePhone,
request.LandlinePhone,
request.DateOfBirth,
request.Gender,
request.CompanyName,
request.Department,
request.JobTitle,
request.CompanyPhone,
request.TaxId,
request.InvoiceTitle,
request.Remark));
return Ok(profile);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileAddressesRead")]
[HttpGet("addresses")]
public async Task<IActionResult> Addresses()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
return Ok(await _profileService.ListAddressesAsync(user.Id));
}
[Authorize(Policy = "ProfileAddressesWrite")]
[HttpPost("addresses")]
public async Task<IActionResult> SaveAddress([FromBody] SaveAddressRequest request)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
try
{
var address = await _profileService.SaveAddressAsync(user.Id, new SaveUserAddressRequest(
request.Id,
request.Label,
request.RecipientName,
request.RecipientPhone,
request.CountryCode,
request.PostalCode,
request.StateRegion,
request.City,
request.District,
request.AddressLine1,
request.AddressLine2,
request.CompanyName,
request.Usage,
request.IsDefault,
request.AddressMetaJson));
return Ok(address);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileAddressesWrite")]
[HttpDelete("addresses/{id:guid}")]
public async Task<IActionResult> DeleteAddress(Guid id)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
try
{
await _profileService.DeleteAddressAsync(user.Id, id);
return NoContent();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileSubscriptionsRead")]
[HttpGet("subscriptions")]
public async Task<IActionResult> Subscriptions()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
return Ok(await _newsletterService.ListSubscriptionsForUserAsync(user.Id));
}
[Authorize(Policy = "ProfileSubscriptionsWrite")]
[HttpPost("subscriptions/{id:guid}/unsubscribe")]
public async Task<IActionResult> Unsubscribe(Guid id)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return Unauthorized();
}
var subscription = await _newsletterService.UnsubscribeForUserAsync(user.Id, id);
return subscription is null ? NotFound() : Ok(subscription);
}
[Authorize(Policy = "ProfileBasicRead")]
[HttpGet("profile/by-email")]
public async Task<IActionResult> ProfileByEmail([FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
var profile = await _profileService.GetProfileAsync(user.Id);
return Ok(profile);
}
[Authorize(Policy = "ProfileBasicWrite")]
[HttpPost("profile/by-email")]
public async Task<IActionResult> SaveProfileByEmail([FromQuery] string email, [FromBody] SaveProfileRequest request)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
try
{
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
request.LastName,
request.FirstName,
request.NickName,
request.MobilePhone,
request.LandlinePhone,
request.DateOfBirth,
request.Gender,
request.CompanyName,
request.Department,
request.JobTitle,
request.CompanyPhone,
request.TaxId,
request.InvoiceTitle,
request.Remark));
return Ok(profile);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileAddressesRead")]
[HttpGet("addresses/by-email")]
public async Task<IActionResult> AddressesByEmail([FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
return Ok(await _profileService.ListAddressesAsync(user.Id));
}
[Authorize(Policy = "ProfileAddressesWrite")]
[HttpPost("addresses/by-email")]
public async Task<IActionResult> SaveAddressByEmail([FromQuery] string email, [FromBody] SaveAddressRequest request)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
try
{
var address = await _profileService.SaveAddressAsync(user.Id, new SaveUserAddressRequest(
request.Id,
request.Label,
request.RecipientName,
request.RecipientPhone,
request.CountryCode,
request.PostalCode,
request.StateRegion,
request.City,
request.District,
request.AddressLine1,
request.AddressLine2,
request.CompanyName,
request.Usage,
request.IsDefault,
request.AddressMetaJson));
return Ok(address);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileAddressesWrite")]
[HttpDelete("addresses/by-email/{id:guid}")]
public async Task<IActionResult> DeleteAddressByEmail(Guid id, [FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
try
{
await _profileService.DeleteAddressAsync(user.Id, id);
return NoContent();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileSubscriptionsRead")]
[HttpGet("subscriptions/by-email")]
public async Task<IActionResult> SubscriptionsByEmail([FromQuery] string email)
{
return Ok(await _newsletterService.ListSubscriptionsByEmailAsync(email));
}
[Authorize(Policy = "ProfileSubscriptionsWrite")]
[HttpPost("subscriptions/by-email/{id:guid}/unsubscribe")]
public async Task<IActionResult> UnsubscribeByEmail(Guid id, [FromQuery] string email)
{
var subscription = await _newsletterService.UnsubscribeByEmailAsync(email, id);
return subscription is null ? NotFound() : Ok(subscription);
}
}

View File

@ -1,4 +1,5 @@
using OpenIddict.Abstractions;
using System.Security.Claims;
namespace MemberCenter.Api.Extensions;
@ -28,4 +29,11 @@ public static class ClaimsExtensions
_ => new[] { OpenIddictConstants.Destinations.AccessToken }
};
}
public static bool HasScope(this ClaimsPrincipal user, string scope)
{
var values = user.FindAll(OpenIddictConstants.Claims.Scope)
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
return values.Contains(scope, StringComparer.Ordinal);
}
}

View File

@ -1,10 +1,15 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Services;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
@ -36,6 +41,9 @@ builder.Services
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.Lockout.AllowedForNewUsers = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
@ -78,6 +86,12 @@ builder.Services.AddOpenIddict()
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
"profile:addresses.write",
"profile:subscriptions.read",
"profile:subscriptions.write",
"newsletter:list.read",
"newsletter:send.write",
"newsletter:send.read",
@ -109,7 +123,14 @@ builder.Services.AddOpenIddict()
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy => policy.RequireRole("admin"));
options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser"));
options.AddPolicy("Superuser", policy => policy.RequireRole("superuser"));
options.AddPolicy("ProfileBasicRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:basic.read")));
options.AddPolicy("ProfileBasicWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:basic.write")));
options.AddPolicy("ProfileAddressesRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:addresses.read")));
options.AddPolicy("ProfileAddressesWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:addresses.write")));
options.AddPolicy("ProfileSubscriptionsRead", policy => policy.RequireAssertion(context => context.User.HasScope("profile:subscriptions.read")));
options.AddPolicy("ProfileSubscriptionsWrite", policy => policy.RequireAssertion(context => context.User.HasScope("profile:subscriptions.write")));
});
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@ -119,12 +140,45 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
options.KnownProxies.Clear();
});
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = static async (context, token) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = Math.Ceiling(retryAfter.TotalSeconds).ToString();
}
await context.HttpContext.Response.WriteAsync("Too many requests.", token);
};
options.AddPolicy(RateLimitPolicyNames.PublicAuthRegister, context =>
CreateFixedWindowLimiter(context, "api-auth-register", permitLimit: 5, TimeSpan.FromMinutes(15)));
options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context =>
CreateFixedWindowLimiter(context, "api-auth-recovery", permitLimit: 5, TimeSpan.FromMinutes(15)));
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterSubscribe, context =>
CreateFixedWindowLimiter(context, "api-newsletter-subscribe", permitLimit: 20, TimeSpan.FromMinutes(10)));
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken, context =>
CreateFixedWindowLimiter(context, "api-newsletter-unsubscribe-token", permitLimit: 10, TimeSpan.FromMinutes(10)));
});
builder.Services.AddControllers();
builder.Services.AddScoped<IAuditLogWriter, AuditLogWriter>();
builder.Services.AddScoped<IAccountGovernanceService, AccountGovernanceService>();
builder.Services.AddScoped<IAccountEmailTemplateService, AccountEmailTemplateService>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IAccountEmailService, AccountEmailService>();
builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
@ -149,7 +203,39 @@ app.Use(async (context, next) =>
});
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.Use(async (context, next) =>
{
if (context.User.Identity?.IsAuthenticated == true)
{
var subject = context.User.FindFirstValue(OpenIddictConstants.Claims.Subject)
?? context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (Guid.TryParse(subject, out var userId))
{
var userManager = context.RequestServices.GetRequiredService<UserManager<ApplicationUser>>();
var user = await userManager.FindByIdAsync(userId.ToString());
if (user is null || user.DisabledAt.HasValue)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Account is disabled.");
return;
}
var tokenSecurityStamp = context.User.FindFirst("AspNet.Identity.SecurityStamp")?.Value;
if (!string.IsNullOrWhiteSpace(tokenSecurityStamp)
&& !string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Session has been invalidated.");
return;
}
}
}
await next();
});
app.UseAuthorization();
app.MapControllers();
@ -196,3 +282,27 @@ static bool IsOpenIddictRequest(PathString path)
|| path.StartsWithSegments("/oauth", StringComparison.OrdinalIgnoreCase)
|| path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase);
}
static RateLimitPartition<string> CreateFixedWindowLimiter(
HttpContext context,
string policyPrefix,
int permitLimit,
TimeSpan window)
{
var identifier = context.User.Identity?.IsAuthenticated == true
? context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.Identity?.Name
?? context.Connection.RemoteIpAddress?.ToString()
?? "unknown"
: context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var partitionKey = $"{policyPrefix}:{identifier}";
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = window,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
AutoReplenishment = true
});
}

View File

@ -0,0 +1,7 @@
namespace MemberCenter.Application.Abstractions;
public interface IAccountEmailService
{
Task SendVerificationEmailAsync(Guid userId, string? fallbackBaseUrl = null);
Task SendPasswordResetEmailAsync(Guid userId, string? fallbackBaseUrl = null);
}

View File

@ -0,0 +1,9 @@
using MemberCenter.Application.Models.Email;
namespace MemberCenter.Application.Abstractions;
public interface IAccountEmailTemplateService
{
EmailTemplate BuildVerificationEmail(string verifyUrl);
EmailTemplate BuildPasswordResetEmail(string resetUrl);
}

View File

@ -0,0 +1,11 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Application.Abstractions;
public interface IAccountGovernanceService
{
Task<IReadOnlyList<UserGovernanceSummaryDto>> ListUsersAsync(string? search = null, int take = 200);
Task SetAdminAsync(Guid actorUserId, Guid targetUserId, bool enabled);
Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled);
Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword);
}

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Abstractions;
public interface IAuditLogWriter
{
Task WriteAsync(string actorType, Guid? actorId, string action, object payload);
}

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Abstractions;
public interface IEmailSender
{
Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null);
}

View File

@ -1,3 +1,4 @@
using MemberCenter.Application.Models.Profile;
using MemberCenter.Application.Models.Newsletter;
namespace MemberCenter.Application.Abstractions;
@ -13,4 +14,8 @@ public interface INewsletterService
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId);
Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsForUserAsync(Guid userId);
Task<UserSubscriptionSummaryDto?> UnsubscribeForUserAsync(Guid userId, Guid subscriptionId);
Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsByEmailAsync(string email);
Task<UserSubscriptionSummaryDto?> UnsubscribeByEmailAsync(string email, Guid subscriptionId);
}

View File

@ -0,0 +1,13 @@
using MemberCenter.Application.Models.Profile;
namespace MemberCenter.Application.Abstractions;
public interface IProfileService
{
Task<UserProfileDto> GetProfileAsync(Guid userId);
Task<UserProfileDto> SaveProfileAsync(Guid userId, SaveUserProfileRequest request);
Task<IReadOnlyList<UserAddressDto>> ListAddressesAsync(Guid userId);
Task<UserAddressDto?> GetAddressAsync(Guid userId, Guid addressId);
Task<UserAddressDto> SaveAddressAsync(Guid userId, SaveUserAddressRequest request);
Task DeleteAddressAsync(Guid userId, Guid addressId);
}

View File

@ -5,5 +5,6 @@ namespace MemberCenter.Application.Abstractions;
public interface ISecuritySettingsService
{
Task<SecuritySettingsDto> GetAsync();
Task SaveAsync(SecuritySettingsDto settings);
Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null);
Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null);
}

View File

@ -0,0 +1,10 @@
namespace MemberCenter.Application.Constants;
public static class RateLimitPolicyNames
{
public const string PublicAuthLogin = "public-auth-login";
public const string PublicAuthRegister = "public-auth-register";
public const string PublicAuthRecovery = "public-auth-recovery";
public const string PublicNewsletterSubscribe = "public-newsletter-subscribe";
public const string PublicNewsletterUnsubscribeToken = "public-newsletter-unsubscribe-token";
}

View File

@ -5,4 +5,5 @@ public sealed record AuditLogDto(
string ActorType,
Guid? ActorId,
string Action,
string? PayloadJson,
DateTimeOffset CreatedAt);

View File

@ -2,4 +2,16 @@ namespace MemberCenter.Application.Models.Admin;
public sealed record SecuritySettingsDto(
int AccessTokenMinutes,
int RefreshTokenDays);
int RefreshTokenDays,
string PublicBaseUrl,
string SmtpRelayHost,
int SmtpRelayPort,
bool SmtpUseTls,
bool SmtpUseSsl,
int SmtpTimeoutSeconds,
string SmtpUsername,
string SmtpPassword,
bool HasSmtpPassword,
string SenderName,
string SenderEmail,
string TestToEmail);

View File

@ -0,0 +1,14 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record SmtpSettingsDto(
string PublicBaseUrl,
string RelayHost,
int RelayPort,
bool UseTls,
bool UseSsl,
int TimeoutSeconds,
string Username,
string Password,
bool HasPassword,
string SenderName,
string SenderEmail);

View File

@ -0,0 +1,18 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record UserGovernanceSummaryDto(
Guid UserId,
string Email,
string? LastName,
string? FirstName,
string? NickName,
bool EmailConfirmed,
bool IsAdmin,
bool IsSuperuser,
bool IsDisabled,
bool IsBlacklisted,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt,
DateTimeOffset? LastSeenAt,
DateTimeOffset? DisabledAt,
string? DisabledBy);

View File

@ -0,0 +1,6 @@
namespace MemberCenter.Application.Models.Email;
public sealed record EmailTemplate(
string Subject,
string TextBody,
string HtmlBody);

View File

@ -0,0 +1,18 @@
namespace MemberCenter.Application.Models.Profile;
public sealed record SaveUserAddressRequest(
Guid? Id,
string Label,
string RecipientName,
string RecipientPhone,
string CountryCode,
string? PostalCode,
string? StateRegion,
string? City,
string? District,
string AddressLine1,
string? AddressLine2,
string? CompanyName,
string Usage,
bool IsDefault,
string? AddressMetaJson);

View File

@ -0,0 +1,17 @@
namespace MemberCenter.Application.Models.Profile;
public sealed record SaveUserProfileRequest(
string LastName,
string FirstName,
string? NickName,
string? MobilePhone,
string? LandlinePhone,
DateOnly? DateOfBirth,
string Gender,
string? CompanyName,
string? Department,
string? JobTitle,
string? CompanyPhone,
string? TaxId,
string? InvoiceTitle,
string? Remark);

View File

@ -0,0 +1,19 @@
namespace MemberCenter.Application.Models.Profile;
public sealed record UserAddressDto(
Guid Id,
Guid UserId,
string Label,
string RecipientName,
string RecipientPhone,
string CountryCode,
string? PostalCode,
string? StateRegion,
string? City,
string? District,
string AddressLine1,
string? AddressLine2,
string? CompanyName,
string Usage,
bool IsDefault,
string? AddressMetaJson);

View File

@ -0,0 +1,19 @@
namespace MemberCenter.Application.Models.Profile;
public sealed record UserProfileDto(
Guid UserId,
string Email,
string LastName,
string FirstName,
string? NickName,
string? MobilePhone,
string? LandlinePhone,
DateOnly? DateOfBirth,
string Gender,
string? CompanyName,
string? Department,
string? JobTitle,
string? CompanyPhone,
string? TaxId,
string? InvoiceTitle,
string? Remark);

View File

@ -0,0 +1,11 @@
namespace MemberCenter.Application.Models.Profile;
public sealed record UserSubscriptionSummaryDto(
Guid Id,
Guid ListId,
Guid TenantId,
string TenantName,
string ListName,
string Email,
string Status,
DateTimeOffset CreatedAt);

View File

@ -0,0 +1,25 @@
using System.Text.Json;
namespace MemberCenter.Domain.Entities;
public sealed class UserAddress
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Label { get; set; } = string.Empty;
public string RecipientName { get; set; } = string.Empty;
public string RecipientPhone { get; set; } = string.Empty;
public string CountryCode { get; set; } = "TW";
public string? PostalCode { get; set; }
public string? StateRegion { get; set; }
public string? City { get; set; }
public string? District { get; set; }
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string? CompanyName { get; set; }
public string Usage { get; set; } = "shipping";
public bool IsDefault { get; set; }
public JsonDocument? AddressMetaJson { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -0,0 +1,21 @@
namespace MemberCenter.Domain.Entities;
public sealed class UserProfile
{
public Guid UserId { get; set; }
public string LastName { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string? NickName { get; set; }
public string? MobilePhone { get; set; }
public string? LandlinePhone { get; set; }
public DateOnly? DateOfBirth { get; set; }
public string Gender { get; set; } = "unspecified";
public string? CompanyName { get; set; }
public string? Department { get; set; }
public string? JobTitle { get; set; }
public string? CompanyPhone { get; set; }
public string? TaxId { get; set; }
public string? InvoiceTitle { get; set; }
public string? Remark { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

View File

@ -1,3 +1,4 @@
using MemberCenter.Domain.Entities;
using Microsoft.AspNetCore.Identity;
namespace MemberCenter.Infrastructure.Identity;
@ -8,4 +9,11 @@ public class ApplicationUser : IdentityUser<Guid>
public bool IsBlacklisted { get; set; }
public DateTimeOffset? BlacklistedAt { get; set; }
public string? BlacklistedBy { get; set; }
public DateTimeOffset? LastLoginAt { get; set; }
public DateTimeOffset? LastSeenAt { get; set; }
public DateTimeOffset? DisabledAt { get; set; }
public string? DisabledBy { get; set; }
public UserProfile? Profile { get; set; }
public List<UserAddress> Addresses { get; set; } = new();
}

View File

@ -16,6 +16,8 @@ public class MemberCenterDbContext
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>();
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<UserAddress> UserAddresses => Set<UserAddress>();
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
@ -76,6 +78,67 @@ public class MemberCenterDbContext
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<UserProfile>(entity =>
{
entity.ToTable("user_profiles");
entity.HasKey(x => x.UserId);
entity.Property(x => x.LastName).IsRequired().HasMaxLength(100);
entity.Property(x => x.FirstName).IsRequired().HasMaxLength(100);
entity.Property(x => x.NickName).HasMaxLength(100);
entity.Property(x => x.MobilePhone).HasMaxLength(50);
entity.Property(x => x.LandlinePhone).HasMaxLength(50);
entity.Property(x => x.Gender).IsRequired().HasMaxLength(20).HasDefaultValue("unspecified");
entity.Property(x => x.CompanyName).HasMaxLength(200);
entity.Property(x => x.Department).HasMaxLength(200);
entity.Property(x => x.JobTitle).HasMaxLength(200);
entity.Property(x => x.CompanyPhone).HasMaxLength(50);
entity.Property(x => x.TaxId).HasMaxLength(32);
entity.Property(x => x.InvoiceTitle).HasMaxLength(200);
entity.Property(x => x.Remark).HasMaxLength(1000);
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
entity.HasOne<ApplicationUser>()
.WithOne(x => x.Profile)
.HasForeignKey<UserProfile>(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<UserAddress>(entity =>
{
entity.ToTable("user_addresses");
entity.HasKey(x => x.Id);
entity.Property(x => x.Label).IsRequired().HasMaxLength(100);
entity.Property(x => x.RecipientName).IsRequired().HasMaxLength(100);
entity.Property(x => x.RecipientPhone).IsRequired().HasMaxLength(50);
entity.Property(x => x.CountryCode).IsRequired().HasMaxLength(2);
entity.Property(x => x.PostalCode).HasMaxLength(20);
entity.Property(x => x.StateRegion).HasMaxLength(100);
entity.Property(x => x.City).HasMaxLength(100);
entity.Property(x => x.District).HasMaxLength(100);
entity.Property(x => x.AddressLine1).IsRequired().HasMaxLength(255);
entity.Property(x => x.AddressLine2).HasMaxLength(255);
entity.Property(x => x.CompanyName).HasMaxLength(200);
entity.Property(x => x.Usage).IsRequired().HasMaxLength(20).HasDefaultValue("shipping");
entity.Property(x => x.IsDefault).HasDefaultValue(false);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
entity.Property(x => x.AddressMetaJson)
.HasColumnType("jsonb")
.HasConversion(
v => v == null ? null : v.RootElement.GetRawText(),
v => string.IsNullOrWhiteSpace(v) ? null : System.Text.Json.JsonDocument.Parse(v, new System.Text.Json.JsonDocumentOptions()));
entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_addresses_user_id");
entity.HasIndex(x => new { x.UserId, x.Usage })
.HasDatabaseName("idx_user_addresses_user_id_usage");
entity.HasIndex(x => new { x.UserId, x.Usage, x.IsDefault })
.IsUnique()
.HasFilter("\"is_default\" = true")
.HasDatabaseName("ux_user_addresses_default_per_usage");
entity.HasOne<ApplicationUser>()
.WithMany(x => x.Addresses)
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<EmailBlacklist>(entity =>
{
entity.ToTable("email_blacklist");

View File

@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MemberCenter.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserProfilesAndAddresses : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DisabledAt",
table: "users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DisabledBy",
table: "users",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastLoginAt",
table: "users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastSeenAt",
table: "users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "user_addresses",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
RecipientName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
RecipientPhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CountryCode = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: false),
PostalCode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
StateRegion = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
District = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
AddressLine1 = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
AddressLine2 = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true),
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Usage = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "shipping"),
IsDefault = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
AddressMetaJson = table.Column<string>(type: "jsonb", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_user_addresses", x => x.Id);
table.ForeignKey(
name: "FK_user_addresses_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_profiles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LastName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
FirstName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
NickName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
MobilePhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
LandlinePhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
Gender = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "unspecified"),
CompanyName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Department = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
JobTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
CompanyPhone = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
TaxId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
InvoiceTitle = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Remark = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_user_profiles", x => x.UserId);
table.ForeignKey(
name: "FK_user_profiles_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql("""
INSERT INTO user_profiles ("UserId", "LastName", "FirstName", "Gender", "UpdatedAt")
SELECT "Id", '', '', 'unspecified', now()
FROM users
WHERE NOT EXISTS (
SELECT 1
FROM user_profiles
WHERE user_profiles."UserId" = users."Id"
);
""");
migrationBuilder.CreateIndex(
name: "idx_user_addresses_user_id",
table: "user_addresses",
column: "UserId");
migrationBuilder.CreateIndex(
name: "idx_user_addresses_user_id_usage",
table: "user_addresses",
columns: new[] { "UserId", "Usage" });
migrationBuilder.CreateIndex(
name: "ux_user_addresses_default_per_usage",
table: "user_addresses",
columns: new[] { "UserId", "Usage", "IsDefault" },
unique: true,
filter: "\"is_default\" = true");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "user_addresses");
migrationBuilder.DropTable(
name: "user_profiles");
migrationBuilder.DropColumn(
name: "DisabledAt",
table: "users");
migrationBuilder.DropColumn(
name: "DisabledBy",
table: "users");
migrationBuilder.DropColumn(
name: "LastLoginAt",
table: "users");
migrationBuilder.DropColumn(
name: "LastSeenAt",
table: "users");
}
}
}

View File

@ -283,6 +283,180 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("unsubscribe_tokens", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AddressLine1")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("AddressLine2")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("AddressMetaJson")
.HasColumnType("jsonb");
b.Property<string>("City")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("CompanyName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("CountryCode")
.IsRequired()
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("District")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsDefault")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PostalCode")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("RecipientName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("RecipientPhone")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("StateRegion")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Usage")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("shipping");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.HasDatabaseName("idx_user_addresses_user_id");
b.HasIndex("UserId", "Usage")
.HasDatabaseName("idx_user_addresses_user_id_usage");
b.HasIndex("UserId", "Usage", "IsDefault")
.IsUnique()
.HasDatabaseName("ux_user_addresses_default_per_usage")
.HasFilter("\"is_default\" = true");
b.ToTable("user_addresses", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("CompanyName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("CompanyPhone")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date");
b.Property<string>("Department")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Gender")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("unspecified");
b.Property<string>("InvoiceTitle")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("JobTitle")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("LandlinePhone")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("MobilePhone")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("NickName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Remark")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("TaxId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.HasKey("UserId");
b.ToTable("user_profiles", (string)null);
});
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b =>
{
b.Property<Guid>("Id")
@ -339,6 +513,12 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<DateTimeOffset?>("DisabledAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisabledBy")
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
@ -351,6 +531,12 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
@ -754,6 +940,24 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.Navigation("Subscription");
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany("Addresses")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfile", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithOne("Profile")
.HasForeignKey("MemberCenter.Domain.Entities.UserProfile", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
@ -839,6 +1043,13 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.Navigation("NewsletterLists");
});
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("Addresses");
b.Navigation("Profile");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");

View File

@ -0,0 +1,88 @@
using System.Net;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Identity;
namespace MemberCenter.Infrastructure.Services;
public sealed class AccountEmailService : IAccountEmailService
{
private readonly ISecuritySettingsService _securitySettingsService;
private readonly IAccountEmailTemplateService _templateService;
private readonly IAuditLogWriter _auditLogWriter;
private readonly IEmailSender _emailSender;
private readonly UserManager<ApplicationUser> _userManager;
public AccountEmailService(
ISecuritySettingsService securitySettingsService,
IAccountEmailTemplateService templateService,
IAuditLogWriter auditLogWriter,
IEmailSender emailSender,
UserManager<ApplicationUser> userManager)
{
_securitySettingsService = securitySettingsService;
_templateService = templateService;
_auditLogWriter = auditLogWriter;
_emailSender = emailSender;
_userManager = userManager;
}
public async Task SendVerificationEmailAsync(Guid userId, string? fallbackBaseUrl = null)
{
var user = await FindUserAsync(userId);
var settings = await _securitySettingsService.GetAsync();
var baseUrl = ResolveBaseUrl(settings.PublicBaseUrl, fallbackBaseUrl);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var link = $"{baseUrl}/account/verifyemail?email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(token)}";
var template = _templateService.BuildVerificationEmail(WebUtility.HtmlEncode(link));
await _emailSender.SendAsync(user.Email ?? string.Empty, template.Subject, template.TextBody, template.HtmlBody);
await _auditLogWriter.WriteAsync("user", user.Id, "account.verification_email_sent", new
{
user_id = user.Id,
email = user.Email
});
}
public async Task SendPasswordResetEmailAsync(Guid userId, string? fallbackBaseUrl = null)
{
var user = await FindUserAsync(userId);
var settings = await _securitySettingsService.GetAsync();
var baseUrl = ResolveBaseUrl(settings.PublicBaseUrl, fallbackBaseUrl);
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var link = $"{baseUrl}/account/resetpassword?email={Uri.EscapeDataString(user.Email ?? string.Empty)}&token={Uri.EscapeDataString(token)}";
var template = _templateService.BuildPasswordResetEmail(WebUtility.HtmlEncode(link));
await _emailSender.SendAsync(user.Email ?? string.Empty, template.Subject, template.TextBody, template.HtmlBody);
await _auditLogWriter.WriteAsync("user", user.Id, "account.password_reset_email_sent", new
{
user_id = user.Id,
email = user.Email
});
}
private static string ResolveBaseUrl(string? configuredBaseUrl, string? fallbackBaseUrl)
{
var value = !string.IsNullOrWhiteSpace(configuredBaseUrl)
? configuredBaseUrl
: fallbackBaseUrl;
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException("Public base URL is not configured.");
}
return value.TrimEnd('/');
}
private async Task<ApplicationUser> FindUserAsync(Guid userId)
{
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user is null || string.IsNullOrWhiteSpace(user.Email))
{
throw new InvalidOperationException("User not found or email is empty.");
}
return user;
}
}

View File

@ -0,0 +1,23 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Email;
namespace MemberCenter.Infrastructure.Services;
public sealed class AccountEmailTemplateService : IAccountEmailTemplateService
{
public EmailTemplate BuildVerificationEmail(string verifyUrl)
{
return new EmailTemplate(
"Verify your email",
$"Please verify your email by opening this link: {verifyUrl}",
$"<p>Please verify your email by clicking <a href=\"{verifyUrl}\">this link</a>.</p>");
}
public EmailTemplate BuildPasswordResetEmail(string resetUrl)
{
return new EmailTemplate(
"Reset your password",
$"Use this link to reset your password: {resetUrl}",
$"<p>Use <a href=\"{resetUrl}\">this link</a> to reset your password.</p>");
}
}

View File

@ -0,0 +1,236 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using OpenIddict.EntityFrameworkCore.Models;
namespace MemberCenter.Infrastructure.Services;
public sealed class AccountGovernanceService : IAccountGovernanceService
{
private const string AdminRole = "admin";
private const string SuperuserRole = "superuser";
private readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<ApplicationRole> _roleManager;
private readonly IAuditLogWriter _auditLogWriter;
public AccountGovernanceService(
MemberCenterDbContext dbContext,
UserManager<ApplicationUser> userManager,
RoleManager<ApplicationRole> roleManager,
IAuditLogWriter auditLogWriter)
{
_dbContext = dbContext;
_userManager = userManager;
_roleManager = roleManager;
_auditLogWriter = auditLogWriter;
}
public async Task<IReadOnlyList<UserGovernanceSummaryDto>> ListUsersAsync(string? search = null, int take = 200)
{
var query = _dbContext.Users
.AsNoTracking()
.GroupJoin(
_dbContext.UserProfiles.AsNoTracking(),
user => user.Id,
profile => profile.UserId,
(user, profiles) => new
{
User = user,
Profile = profiles.FirstOrDefault()
});
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim().ToLower();
query = query.Where(x =>
(x.User.Email ?? string.Empty).ToLower().Contains(term)
|| (x.Profile != null && (
x.Profile.LastName.ToLower().Contains(term)
|| x.Profile.FirstName.ToLower().Contains(term)
|| (x.Profile.NickName != null && x.Profile.NickName.ToLower().Contains(term)))));
}
var users = await query
.OrderByDescending(x => x.User.CreatedAt)
.Take(Math.Clamp(take, 1, 500))
.ToListAsync();
var result = new List<UserGovernanceSummaryDto>(users.Count);
foreach (var item in users)
{
var roles = await _userManager.GetRolesAsync(item.User);
result.Add(new UserGovernanceSummaryDto(
item.User.Id,
item.User.Email ?? string.Empty,
item.Profile?.LastName,
item.Profile?.FirstName,
item.Profile?.NickName,
item.User.EmailConfirmed,
roles.Contains(AdminRole, StringComparer.OrdinalIgnoreCase),
roles.Contains(SuperuserRole, StringComparer.OrdinalIgnoreCase),
item.User.DisabledAt.HasValue,
item.User.IsBlacklisted,
item.User.CreatedAt,
item.User.LastLoginAt,
item.User.LastSeenAt,
item.User.DisabledAt,
item.User.DisabledBy));
}
return result;
}
public async Task SetAdminAsync(Guid actorUserId, Guid targetUserId, bool enabled)
{
await EnsureSuperuserAsync(actorUserId);
await EnsureRoleExistsAsync(AdminRole);
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
?? throw new InvalidOperationException("Target user not found.");
await EnsureTargetIsMutableAsync(targetUser);
var inRole = await _userManager.IsInRoleAsync(targetUser, AdminRole);
if (enabled && !inRole)
{
EnsureSucceeded(await _userManager.AddToRoleAsync(targetUser, AdminRole));
}
else if (!enabled && inRole)
{
EnsureSucceeded(await _userManager.RemoveFromRoleAsync(targetUser, AdminRole));
}
await _auditLogWriter.WriteAsync("user", actorUserId, "account.role_changed", new
{
target_user_id = targetUser.Id,
email = targetUser.Email,
role = AdminRole,
enabled
});
}
public async Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled)
{
await EnsureSuperuserAsync(actorUserId);
if (actorUserId == targetUserId)
{
throw new InvalidOperationException("You cannot disable your own account.");
}
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
?? throw new InvalidOperationException("Target user not found.");
await EnsureTargetIsMutableAsync(targetUser);
targetUser.DisabledAt = disabled ? DateTimeOffset.UtcNow : null;
targetUser.DisabledBy = disabled ? actorUserId.ToString() : null;
EnsureSucceeded(await _userManager.UpdateAsync(targetUser));
await _auditLogWriter.WriteAsync("user", actorUserId, disabled ? "account.disabled" : "account.enabled", new
{
target_user_id = targetUser.Id,
email = targetUser.Email
});
}
public async Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword)
{
await EnsureSuperuserAsync(actorUserId);
var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString())
?? throw new InvalidOperationException("Target user not found.");
await EnsureTargetIsMutableAsync(targetUser);
if (string.IsNullOrWhiteSpace(newPassword))
{
throw new InvalidOperationException("New password is required.");
}
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(targetUser);
EnsureSucceeded(await _userManager.ResetPasswordAsync(targetUser, resetToken, newPassword));
EnsureSucceeded(await _userManager.UpdateSecurityStampAsync(targetUser));
await RevokeUserAuthorizationsAsync(targetUser.Id);
await _auditLogWriter.WriteAsync("user", actorUserId, "account.password_reset_by_superuser", new
{
target_user_id = targetUser.Id,
email = targetUser.Email,
revoke_existing_sessions = true
});
}
private async Task EnsureSuperuserAsync(Guid actorUserId)
{
await EnsureRoleExistsAsync(SuperuserRole);
var actor = await _userManager.FindByIdAsync(actorUserId.ToString())
?? throw new InvalidOperationException("Actor user not found.");
if (!await _userManager.IsInRoleAsync(actor, SuperuserRole))
{
throw new InvalidOperationException("Only superuser can modify account governance.");
}
}
private async Task EnsureTargetIsMutableAsync(ApplicationUser targetUser)
{
if (await _userManager.IsInRoleAsync(targetUser, SuperuserRole))
{
throw new InvalidOperationException("Superuser accounts cannot be modified from the management UI.");
}
}
private async Task EnsureRoleExistsAsync(string roleName)
{
if (await _roleManager.RoleExistsAsync(roleName))
{
return;
}
EnsureSucceeded(await _roleManager.CreateAsync(new ApplicationRole
{
Id = Guid.NewGuid(),
Name = roleName,
NormalizedName = roleName.ToUpperInvariant()
}));
}
private static void EnsureSucceeded(IdentityResult result)
{
if (result.Succeeded)
{
return;
}
throw new InvalidOperationException(string.Join("; ", result.Errors.Select(x => x.Description)));
}
private async Task RevokeUserAuthorizationsAsync(Guid userId)
{
var subject = userId.ToString();
var tokens = await _dbContext.Set<OpenIddictEntityFrameworkCoreToken>()
.Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked)
.ToListAsync();
foreach (var token in tokens)
{
token.Status = OpenIddictConstants.Statuses.Revoked;
token.RedemptionDate = DateTime.UtcNow;
}
var authorizations = await _dbContext.Set<OpenIddictEntityFrameworkCoreAuthorization>()
.Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked)
.ToListAsync();
foreach (var authorization in authorizations)
{
authorization.Status = OpenIddictConstants.Statuses.Revoked;
}
await _dbContext.SaveChangesAsync();
}
}

View File

@ -16,17 +16,20 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
private readonly UserManager<ApplicationUser> _userManager;
private readonly MemberCenterDbContext _dbContext;
private readonly ISendEngineWebhookPublisher _webhookPublisher;
private readonly IAuditLogWriter _auditLogWriter;
private readonly ILogger<AccountProvisioningService> _logger;
public AccountProvisioningService(
UserManager<ApplicationUser> userManager,
MemberCenterDbContext dbContext,
ISendEngineWebhookPublisher webhookPublisher,
IAuditLogWriter auditLogWriter,
ILogger<AccountProvisioningService> logger)
{
_userManager = userManager;
_dbContext = dbContext;
_webhookPublisher = webhookPublisher;
_auditLogWriter = auditLogWriter;
_logger = logger;
}
@ -47,11 +50,28 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
return Failed(result);
}
_dbContext.UserProfiles.Add(new UserProfile
{
UserId = user.Id,
LastName = string.Empty,
FirstName = string.Empty,
Gender = "unspecified",
UpdatedAt = DateTimeOffset.UtcNow
});
await _dbContext.SaveChangesAsync();
var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(user, "local_registration", new
{
email = normalizedEmail
});
await _auditLogWriter.WriteAsync("user", user.Id, "account.registered", new
{
user_id = user.Id,
email = normalizedEmail,
registration_type = "local"
});
return new AccountProvisioningResult(
true,
user.Id,
@ -121,6 +141,16 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
return Failed(createResult);
}
_dbContext.UserProfiles.Add(new UserProfile
{
UserId = user.Id,
LastName = string.Empty,
FirstName = string.Empty,
Gender = "unspecified",
UpdatedAt = DateTimeOffset.UtcNow
});
await _dbContext.SaveChangesAsync();
createdUser = true;
}
else if (emailVerified && !user.EmailConfirmed)
@ -142,6 +172,14 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
created_user = createdUser
});
await _auditLogWriter.WriteAsync("user", user.Id, "account.external_login_linked", new
{
user_id = user.Id,
email = user.Email,
login_provider = loginProvider,
created_user = createdUser
});
return new AccountProvisioningResult(
true,
user.Id,

View File

@ -22,7 +22,7 @@ public sealed class AuditLogService : IAuditLogService
.ToListAsync();
return logs
.Select(l => new AuditLogDto(l.Id, l.ActorType, l.ActorId, l.Action, l.CreatedAt))
.Select(l => new AuditLogDto(l.Id, l.ActorType, l.ActorId, l.Action, l.Payload.RootElement.GetRawText(), l.CreatedAt))
.ToList();
}
}

View File

@ -0,0 +1,29 @@
using System.Text.Json;
using MemberCenter.Application.Abstractions;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
namespace MemberCenter.Infrastructure.Services;
public sealed class AuditLogWriter : IAuditLogWriter
{
private readonly MemberCenterDbContext _dbContext;
public AuditLogWriter(MemberCenterDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task WriteAsync(string actorType, Guid? actorId, string action, object payload)
{
_dbContext.AuditLogs.Add(new AuditLog
{
Id = Guid.NewGuid(),
ActorType = actorType,
ActorId = actorId,
Action = action,
Payload = JsonDocument.Parse(JsonSerializer.Serialize(payload))
});
await _dbContext.SaveChangesAsync();
}
}

View File

@ -1,5 +1,6 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Newsletter;
using MemberCenter.Application.Models.Profile;
using MemberCenter.Domain.Constants;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
@ -358,6 +359,122 @@ public sealed class NewsletterService : INewsletterService
return subscriptions.Select(MapSubscription).ToList();
}
public async Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsForUserAsync(Guid userId)
{
return await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
where subscription.UserId == userId
orderby tenant.Name, list.Name
select new UserSubscriptionSummaryDto(
subscription.Id,
subscription.ListId,
tenant.Id,
tenant.Name,
list.Name,
subscription.Email,
subscription.Status,
subscription.CreatedAt))
.ToListAsync();
}
public async Task<UserSubscriptionSummaryDto?> UnsubscribeForUserAsync(Guid userId, Guid subscriptionId)
{
var subscription = await _dbContext.NewsletterSubscriptions
.FirstOrDefaultAsync(x => x.Id == subscriptionId && x.UserId == userId);
if (subscription is null)
{
return null;
}
if (await _emailBlacklist.IsBlacklistedAsync(subscription.Email))
{
return null;
}
subscription.Status = SubscriptionStatus.Unsubscribed;
await _dbContext.SaveChangesAsync();
var updated = await (
from saved in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on saved.ListId equals list.Id
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
where saved.Id == subscriptionId
select new UserSubscriptionSummaryDto(
saved.Id,
saved.ListId,
tenant.Id,
tenant.Name,
list.Name,
saved.Email,
saved.Status,
saved.CreatedAt))
.FirstAsync();
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", MapSubscription(subscription));
return updated;
}
public async Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsByEmailAsync(string email)
{
var normalizedEmail = email.Trim().ToLowerInvariant();
return await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
where subscription.Email.ToLower() == normalizedEmail
orderby tenant.Name, list.Name
select new UserSubscriptionSummaryDto(
subscription.Id,
subscription.ListId,
tenant.Id,
tenant.Name,
list.Name,
subscription.Email,
subscription.Status,
subscription.CreatedAt))
.ToListAsync();
}
public async Task<UserSubscriptionSummaryDto?> UnsubscribeByEmailAsync(string email, Guid subscriptionId)
{
var normalizedEmail = email.Trim().ToLowerInvariant();
var subscription = await _dbContext.NewsletterSubscriptions
.FirstOrDefaultAsync(x => x.Id == subscriptionId && x.Email.ToLower() == normalizedEmail);
if (subscription is null)
{
return null;
}
if (await _emailBlacklist.IsBlacklistedAsync(subscription.Email))
{
return null;
}
subscription.Status = SubscriptionStatus.Unsubscribed;
await _dbContext.SaveChangesAsync();
var updated = await (
from saved in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on saved.ListId equals list.Id
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
where saved.Id == subscriptionId
select new UserSubscriptionSummaryDto(
saved.Id,
saved.ListId,
tenant.Id,
tenant.Name,
list.Name,
saved.Email,
saved.Status,
saved.CreatedAt))
.FirstAsync();
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", MapSubscription(subscription));
return updated;
}
private static string CreateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);

View File

@ -0,0 +1,340 @@
using System.Text.Json;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Profile;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace MemberCenter.Infrastructure.Services;
public sealed class ProfileService : IProfileService
{
private static readonly HashSet<string> AllowedGenders = new(StringComparer.OrdinalIgnoreCase)
{
"male",
"female",
"other",
"unspecified"
};
private static readonly HashSet<string> AllowedAddressUsages = new(StringComparer.OrdinalIgnoreCase)
{
"shipping",
"billing",
"both"
};
private readonly MemberCenterDbContext _dbContext;
private readonly IAuditLogWriter _auditLogWriter;
private readonly UserManager<ApplicationUser> _userManager;
public ProfileService(MemberCenterDbContext dbContext, IAuditLogWriter auditLogWriter, UserManager<ApplicationUser> userManager)
{
_dbContext = dbContext;
_auditLogWriter = auditLogWriter;
_userManager = userManager;
}
public async Task<UserProfileDto> GetProfileAsync(Guid userId)
{
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user is null)
{
throw new InvalidOperationException("User not found.");
}
var profile = await EnsureProfileAsync(userId);
await _dbContext.SaveChangesAsync();
return MapProfile(profile, user.Email ?? string.Empty);
}
public async Task<UserProfileDto> SaveProfileAsync(Guid userId, SaveUserProfileRequest request)
{
var user = await _userManager.FindByIdAsync(userId.ToString());
if (user is null)
{
throw new InvalidOperationException("User not found.");
}
var profile = await EnsureProfileAsync(userId);
profile.LastName = RequireValue(request.LastName, "LastName", 100);
profile.FirstName = RequireValue(request.FirstName, "FirstName", 100);
profile.NickName = CleanValue(request.NickName, 100);
profile.MobilePhone = CleanValue(request.MobilePhone, 50);
profile.LandlinePhone = CleanValue(request.LandlinePhone, 50);
profile.DateOfBirth = request.DateOfBirth;
profile.Gender = NormalizeGender(request.Gender);
profile.CompanyName = CleanValue(request.CompanyName, 200);
profile.Department = CleanValue(request.Department, 200);
profile.JobTitle = CleanValue(request.JobTitle, 200);
profile.CompanyPhone = CleanValue(request.CompanyPhone, 50);
profile.TaxId = CleanValue(request.TaxId, 32);
profile.InvoiceTitle = CleanValue(request.InvoiceTitle, 200);
profile.Remark = CleanValue(request.Remark, 1000);
profile.UpdatedAt = DateTimeOffset.UtcNow;
await _auditLogWriter.WriteAsync("user", userId, "profile.updated", new
{
user_id = userId,
email = user.Email,
profile.Gender
});
await _dbContext.SaveChangesAsync();
return MapProfile(profile, user.Email ?? string.Empty);
}
public async Task<IReadOnlyList<UserAddressDto>> ListAddressesAsync(Guid userId)
{
var addresses = await _dbContext.UserAddresses
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.IsDefault)
.ThenBy(x => x.Label)
.ToListAsync();
return addresses.Select(MapAddress).ToList();
}
public async Task<UserAddressDto?> GetAddressAsync(Guid userId, Guid addressId)
{
var address = await _dbContext.UserAddresses
.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == addressId);
return address is null ? null : MapAddress(address);
}
public async Task<UserAddressDto> SaveAddressAsync(Guid userId, SaveUserAddressRequest request)
{
var usage = NormalizeUsage(request.Usage);
var address = request.Id.HasValue
? await _dbContext.UserAddresses.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == request.Id.Value)
: null;
if (request.Id.HasValue && address is null)
{
throw new InvalidOperationException("Address not found.");
}
if (address is null)
{
address = new UserAddress
{
Id = Guid.NewGuid(),
UserId = userId,
CreatedAt = DateTimeOffset.UtcNow
};
_dbContext.UserAddresses.Add(address);
}
address.Label = RequireValue(request.Label, "Label", 100);
address.RecipientName = RequireValue(request.RecipientName, "RecipientName", 100);
address.RecipientPhone = RequireValue(request.RecipientPhone, "RecipientPhone", 50);
address.CountryCode = RequireCountryCode(request.CountryCode);
address.PostalCode = CleanValue(request.PostalCode, 20);
address.StateRegion = CleanValue(request.StateRegion, 100);
address.City = CleanValue(request.City, 100);
address.District = CleanValue(request.District, 100);
address.AddressLine1 = RequireValue(request.AddressLine1, "AddressLine1", 255);
address.AddressLine2 = CleanValue(request.AddressLine2, 255);
address.CompanyName = CleanValue(request.CompanyName, 200);
address.Usage = usage;
address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson);
address.UpdatedAt = DateTimeOffset.UtcNow;
var existingWithUsage = await _dbContext.UserAddresses
.Where(x => x.UserId == userId && x.Usage == usage && x.Id != address.Id)
.ToListAsync();
var shouldBeDefault = request.IsDefault || !existingWithUsage.Any();
address.IsDefault = shouldBeDefault;
if (shouldBeDefault)
{
foreach (var item in existingWithUsage)
{
item.IsDefault = false;
item.UpdatedAt = DateTimeOffset.UtcNow;
}
}
await _auditLogWriter.WriteAsync("user", userId, request.Id.HasValue ? "address.updated" : "address.created", new
{
user_id = userId,
address_id = address.Id,
address.Usage,
address.IsDefault
});
await _dbContext.SaveChangesAsync();
return MapAddress(address);
}
public async Task DeleteAddressAsync(Guid userId, Guid addressId)
{
var addresses = await _dbContext.UserAddresses
.Where(x => x.UserId == userId)
.OrderByDescending(x => x.IsDefault)
.ThenBy(x => x.CreatedAt)
.ToListAsync();
var address = addresses.FirstOrDefault(x => x.Id == addressId);
if (address is null)
{
throw new InvalidOperationException("Address not found.");
}
if (addresses.Count == 1)
{
throw new InvalidOperationException("Cannot delete the last address.");
}
_dbContext.UserAddresses.Remove(address);
if (address.IsDefault)
{
var replacement = addresses
.Where(x => x.Id != addressId && string.Equals(x.Usage, address.Usage, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefault();
if (replacement is not null)
{
replacement.IsDefault = true;
replacement.UpdatedAt = DateTimeOffset.UtcNow;
}
}
await _auditLogWriter.WriteAsync("user", userId, "address.deleted", new
{
user_id = userId,
address_id = addressId
});
await _dbContext.SaveChangesAsync();
}
private async Task<UserProfile> EnsureProfileAsync(Guid userId)
{
var profile = await _dbContext.UserProfiles.FirstOrDefaultAsync(x => x.UserId == userId);
if (profile is not null)
{
return profile;
}
profile = new UserProfile
{
UserId = userId,
LastName = string.Empty,
FirstName = string.Empty,
Gender = "unspecified",
UpdatedAt = DateTimeOffset.UtcNow
};
_dbContext.UserProfiles.Add(profile);
return profile;
}
private static UserProfileDto MapProfile(UserProfile profile, string email) =>
new(
profile.UserId,
email,
profile.LastName,
profile.FirstName,
profile.NickName,
profile.MobilePhone,
profile.LandlinePhone,
profile.DateOfBirth,
profile.Gender,
profile.CompanyName,
profile.Department,
profile.JobTitle,
profile.CompanyPhone,
profile.TaxId,
profile.InvoiceTitle,
profile.Remark);
private static UserAddressDto MapAddress(UserAddress address) =>
new(
address.Id,
address.UserId,
address.Label,
address.RecipientName,
address.RecipientPhone,
address.CountryCode,
address.PostalCode,
address.StateRegion,
address.City,
address.District,
address.AddressLine1,
address.AddressLine2,
address.CompanyName,
address.Usage,
address.IsDefault,
address.AddressMetaJson?.RootElement.GetRawText());
private static string RequireValue(string? value, string fieldName, int maxLength)
{
var cleaned = CleanValue(value, maxLength);
if (string.IsNullOrWhiteSpace(cleaned))
{
throw new InvalidOperationException($"{fieldName} is required.");
}
return cleaned;
}
private static string? CleanValue(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (trimmed.Length > maxLength)
{
throw new InvalidOperationException($"Value exceeds max length {maxLength}.");
}
return trimmed;
}
private static string NormalizeGender(string? value)
{
var normalized = CleanValue(value, 20)?.ToLowerInvariant() ?? "unspecified";
if (!AllowedGenders.Contains(normalized))
{
throw new InvalidOperationException("Invalid gender.");
}
return normalized;
}
private static string NormalizeUsage(string? value)
{
var normalized = RequireValue(value, "Usage", 20).ToLowerInvariant();
if (!AllowedAddressUsages.Contains(normalized))
{
throw new InvalidOperationException("Invalid address usage.");
}
return normalized;
}
private static string RequireCountryCode(string? value)
{
var normalized = RequireValue(value, "CountryCode", 2).ToUpperInvariant();
if (normalized.Length != 2)
{
throw new InvalidOperationException("CountryCode must be 2 characters.");
}
return normalized;
}
private static JsonDocument? ParseOptionalJson(string? raw)
{
var cleaned = CleanValue(raw, 4000);
return string.IsNullOrWhiteSpace(cleaned) ? null : JsonDocument.Parse(cleaned);
}
}

View File

@ -3,6 +3,8 @@ using MemberCenter.Application.Models.Admin;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Net.Mail;
namespace MemberCenter.Infrastructure.Services;
@ -10,28 +12,171 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
{
private const string AccessTokenKey = "token_access_minutes";
private const string RefreshTokenKey = "token_refresh_days";
private const string PublicBaseUrlKey = "public_base_url";
private const string SmtpRelayHostKey = "smtp_relay_host";
private const string SmtpRelayPortKey = "smtp_relay_port";
private const string SmtpUseTlsKey = "smtp_use_tls";
private const string SmtpUseSslKey = "smtp_use_ssl";
private const string SmtpTimeoutSecondsKey = "smtp_timeout_seconds";
private const string SmtpUsernameKey = "smtp_username";
private const string SmtpPasswordKey = "smtp_password";
private const string SenderNameKey = "smtp_sender_name";
private const string SenderEmailKey = "smtp_sender_email";
private readonly MemberCenterDbContext _dbContext;
private readonly IAuditLogWriter _auditLogWriter;
public SecuritySettingsService(MemberCenterDbContext dbContext)
public SecuritySettingsService(
MemberCenterDbContext dbContext,
IAuditLogWriter auditLogWriter)
{
_dbContext = dbContext;
_auditLogWriter = auditLogWriter;
}
public async Task<SecuritySettingsDto> GetAsync()
{
var access = await GetFlagAsync(AccessTokenKey, 60);
var refresh = await GetFlagAsync(RefreshTokenKey, 30);
return new SecuritySettingsDto(access, refresh);
var smtp = await GetSmtpSettingsAsync();
return new SecuritySettingsDto(
access,
refresh,
smtp.PublicBaseUrl,
smtp.RelayHost,
smtp.RelayPort,
smtp.UseTls,
smtp.UseSsl,
smtp.TimeoutSeconds,
smtp.Username,
string.Empty,
smtp.HasPassword,
smtp.SenderName,
smtp.SenderEmail,
string.Empty);
}
public async Task SaveAsync(SecuritySettingsDto settings)
public async Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null)
{
if (settings.SmtpUseTls && settings.SmtpUseSsl)
{
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
}
await SetFlagAsync(AccessTokenKey, settings.AccessTokenMinutes.ToString());
await SetFlagAsync(RefreshTokenKey, settings.RefreshTokenDays.ToString());
await SetFlagAsync(PublicBaseUrlKey, settings.PublicBaseUrl.Trim());
await SetFlagAsync(SmtpRelayHostKey, settings.SmtpRelayHost.Trim());
await SetFlagAsync(SmtpRelayPortKey, settings.SmtpRelayPort.ToString());
await SetFlagAsync(SmtpUseTlsKey, settings.SmtpUseTls.ToString());
await SetFlagAsync(SmtpUseSslKey, settings.SmtpUseSsl.ToString());
await SetFlagAsync(SmtpTimeoutSecondsKey, settings.SmtpTimeoutSeconds.ToString());
await SetFlagAsync(SmtpUsernameKey, settings.SmtpUsername.Trim());
if (!string.IsNullOrWhiteSpace(settings.SmtpPassword))
{
await SetFlagAsync(SmtpPasswordKey, settings.SmtpPassword);
}
await SetFlagAsync(SenderNameKey, settings.SenderName.Trim());
await SetFlagAsync(SenderEmailKey, settings.SenderEmail.Trim());
await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_settings_updated", new
{
settings.AccessTokenMinutes,
settings.RefreshTokenDays,
settings.PublicBaseUrl,
settings.SmtpRelayHost,
settings.SmtpRelayPort,
settings.SmtpUseTls,
settings.SmtpUseSsl,
settings.SmtpTimeoutSeconds,
settings.SmtpUsername,
settings.SenderName,
settings.SenderEmail
});
await _dbContext.SaveChangesAsync();
}
public async Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null)
{
var smtp = await GetSmtpSettingsAsync();
ValidateSmtpSettings(smtp);
using var message = new MailMessage
{
Subject = "[SMTP Test] Member Center SMTP 設定測試",
Body = "這是一封測試信,代表 SMTP 設定可正常寄送。",
IsBodyHtml = false,
From = new MailAddress(smtp.SenderEmail, smtp.SenderName)
};
message.To.Add(new MailAddress(toEmail));
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString("<p>這是一封測試信,代表 SMTP 設定可正常寄送。</p>", null, "text/html"));
using var client = new SmtpClient(smtp.RelayHost, smtp.RelayPort)
{
EnableSsl = smtp.UseSsl || smtp.UseTls,
DeliveryMethod = SmtpDeliveryMethod.Network,
Timeout = Math.Max(1000, smtp.TimeoutSeconds * 1000)
};
if (!string.IsNullOrWhiteSpace(smtp.Username))
{
client.Credentials = new NetworkCredential(smtp.Username, smtp.Password);
}
await client.SendMailAsync(message);
await _auditLogWriter.WriteAsync("user", actorUserId, "system.security_test_email_sent", new
{
to_email = toEmail,
sender_email = smtp.SenderEmail
});
return 1;
}
private async Task<SmtpSettingsDto> GetSmtpSettingsAsync()
{
var relayHost = await GetFlagAsync(SmtpRelayHostKey, string.Empty);
var publicBaseUrl = await GetFlagAsync(PublicBaseUrlKey, string.Empty);
var relayPort = await GetFlagAsync(SmtpRelayPortKey, 587);
var useTls = await GetFlagAsync(SmtpUseTlsKey, true);
var useSsl = await GetFlagAsync(SmtpUseSslKey, false);
var timeoutSeconds = await GetFlagAsync(SmtpTimeoutSecondsKey, 15);
var username = await GetFlagAsync(SmtpUsernameKey, string.Empty);
var password = await GetFlagAsync(SmtpPasswordKey, string.Empty);
var senderName = await GetFlagAsync(SenderNameKey, "Member Center");
var senderEmail = await GetFlagAsync(SenderEmailKey, string.Empty);
return new SmtpSettingsDto(
publicBaseUrl,
relayHost,
relayPort,
useTls,
useSsl,
timeoutSeconds,
username,
password,
!string.IsNullOrWhiteSpace(password),
senderName,
senderEmail);
}
private static void ValidateSmtpSettings(SmtpSettingsDto smtp)
{
if (string.IsNullOrWhiteSpace(smtp.RelayHost))
{
throw new InvalidOperationException("SMTP relay host is empty. Please save SMTP settings first.");
}
if (smtp.UseTls && smtp.UseSsl)
{
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
}
if (string.IsNullOrWhiteSpace(smtp.SenderEmail))
{
throw new InvalidOperationException("Sender email is empty. Please save sender settings first.");
}
}
private async Task<int> GetFlagAsync(string key, int defaultValue)
{
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
@ -43,6 +188,23 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
return int.TryParse(flag.Value, out var value) ? value : defaultValue;
}
private async Task<bool> GetFlagAsync(string key, bool defaultValue)
{
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
if (flag is null)
{
return defaultValue;
}
return bool.TryParse(flag.Value, out var value) ? value : defaultValue;
}
private async Task<string> GetFlagAsync(string key, string defaultValue)
{
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
return flag?.Value ?? defaultValue;
}
private async Task SetFlagAsync(string key, string value)
{
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == key);
@ -62,4 +224,5 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
flag.UpdatedAt = DateTimeOffset.UtcNow;
}
}
}

View File

@ -0,0 +1,72 @@
using System.Net;
using System.Net.Mail;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Infrastructure.Services;
public sealed class SmtpEmailSender : IEmailSender
{
private readonly ISecuritySettingsService _securitySettingsService;
public SmtpEmailSender(ISecuritySettingsService securitySettingsService)
{
_securitySettingsService = securitySettingsService;
}
public async Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null)
{
var settings = await _securitySettingsService.GetAsync();
Validate(settings);
using var message = new MailMessage
{
Subject = subject,
Body = htmlBody ?? textBody,
IsBodyHtml = !string.IsNullOrWhiteSpace(htmlBody),
From = new MailAddress(settings.SenderEmail, settings.SenderName)
};
message.To.Add(new MailAddress(toEmail));
if (!string.IsNullOrWhiteSpace(textBody) && !string.IsNullOrWhiteSpace(htmlBody))
{
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(textBody, null, "text/plain"));
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(htmlBody, null, "text/html"));
message.Body = textBody;
message.IsBodyHtml = false;
}
using var client = new SmtpClient(settings.SmtpRelayHost, settings.SmtpRelayPort)
{
EnableSsl = settings.SmtpUseSsl || settings.SmtpUseTls,
DeliveryMethod = SmtpDeliveryMethod.Network,
Timeout = Math.Max(1000, settings.SmtpTimeoutSeconds * 1000)
};
if (!string.IsNullOrWhiteSpace(settings.SmtpUsername))
{
client.Credentials = new NetworkCredential(settings.SmtpUsername, settings.SmtpPassword);
}
await client.SendMailAsync(message);
return 1;
}
private static void Validate(SecuritySettingsDto settings)
{
if (string.IsNullOrWhiteSpace(settings.SmtpRelayHost))
{
throw new InvalidOperationException("SMTP relay host is empty. Please save SMTP settings first.");
}
if (settings.SmtpUseTls && settings.SmtpUseSsl)
{
throw new InvalidOperationException("SMTP TLS and SSL cannot both be enabled.");
}
if (string.IsNullOrWhiteSpace(settings.SenderEmail))
{
throw new InvalidOperationException("Sender email is empty. Please save sender settings first.");
}
}
}

View File

@ -54,7 +54,7 @@ var targetMigrationOption = new Option<string?>(
name: "--target",
description: "Target migration");
var initCommand = new Command("init", "Initialize database (migrate + seed + admin)");
var initCommand = new Command("init", "Initialize database (migrate + seed + superuser)");
initCommand.AddOption(connectionStringOption);
initCommand.AddOption(appsettingsOption);
initCommand.AddOption(noPromptOption);
@ -87,19 +87,20 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password");
adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("Superuser password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
Console.Error.WriteLine("Superuser password is required.");
return;
}
await db.Database.MigrateAsync();
await EnsureRoleAsync(roleManager, "superuser");
await EnsureRoleAsync(roleManager, "admin");
await EnsureRoleAsync(roleManager, "support");
@ -121,9 +122,9 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
}
}
if (!await userManager.IsInRoleAsync(admin, "admin"))
if (!await userManager.IsInRoleAsync(admin, "superuser"))
{
await userManager.AddToRoleAsync(admin, "admin");
await userManager.AddToRoleAsync(admin, "superuser");
}
await SetInstalledFlagAsync(db);
@ -134,7 +135,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
}
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
var addAdminCommand = new Command("add-admin", "Add admin user");
var addAdminCommand = new Command("add-superuser", "Add or elevate superuser");
addAdminCommand.AddAlias("add-admin");
addAdminCommand.AddOption(connectionStringOption);
addAdminCommand.AddOption(appsettingsOption);
addAdminCommand.AddOption(noPromptOption);
@ -151,14 +153,14 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password");
adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("Superuser password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
Console.Error.WriteLine("Superuser password is required.");
return;
}
@ -167,7 +169,7 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
await EnsureRoleAsync(roleManager, "admin");
await EnsureRoleAsync(roleManager, "superuser");
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
@ -187,15 +189,16 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
}
}
if (!await userManager.IsInRoleAsync(admin, "admin"))
if (!await userManager.IsInRoleAsync(admin, "superuser"))
{
await userManager.AddToRoleAsync(admin, "admin");
await userManager.AddToRoleAsync(admin, "superuser");
}
Console.WriteLine("Admin user ready.");
Console.WriteLine("Superuser ready.");
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
var resetCommand = new Command("reset-admin-password", "Reset admin password");
var resetCommand = new Command("reset-superuser-password", "Reset superuser password");
resetCommand.AddAlias("reset-admin-password");
resetCommand.AddOption(connectionStringOption);
resetCommand.AddOption(appsettingsOption);
resetCommand.AddOption(noPromptOption);
@ -212,14 +215,14 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
if (!noPrompt)
{
adminEmail ??= Prompt("Admin email", "admin@example.com");
adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("New password");
}
adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword))
{
Console.Error.WriteLine("Admin password is required.");
Console.Error.WriteLine("Superuser password is required.");
return;
}
@ -230,7 +233,7 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null)
{
Console.Error.WriteLine("Admin not found.");
Console.Error.WriteLine("Superuser not found.");
return;
}

View File

@ -0,0 +1,154 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Areas.Admin.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace MemberCenter.Web.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "Admin")]
[Route("admin/accounts")]
public class AccountsController : Controller
{
private readonly IAccountGovernanceService _accountGovernanceService;
private readonly UserManager<ApplicationUser> _userManager;
public AccountsController(
IAccountGovernanceService accountGovernanceService,
UserManager<ApplicationUser> userManager)
{
_accountGovernanceService = accountGovernanceService;
_userManager = userManager;
}
[HttpGet("")]
public async Task<IActionResult> Index(string? search = null, string? role = null, string? status = null, string? verified = null)
{
var items = await _accountGovernanceService.ListUsersAsync(search);
items = ApplyFilters(items, role, status, verified);
return View(new AccountsIndexViewModel
{
Search = search,
RoleFilter = role,
StatusFilter = status,
VerifiedFilter = verified,
CanManage = User.IsInRole("superuser"),
Items = items
});
}
[HttpPost("{id:guid}/admin")]
[Authorize(Policy = "Superuser")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SetAdmin(Guid id, bool enabled, string? search = null, string? role = null, string? status = null, string? verified = null)
{
var actorId = await GetCurrentUserIdAsync();
if (!actorId.HasValue)
{
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
try
{
await _accountGovernanceService.SetAdminAsync(actorId.Value, id, enabled);
TempData["Result"] = enabled ? "Admin granted." : "Admin removed.";
}
catch (InvalidOperationException ex)
{
TempData["Error"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { search, role, status, verified });
}
[HttpPost("{id:guid}/disabled")]
[Authorize(Policy = "Superuser")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SetDisabled(Guid id, bool disabled, string? search = null, string? role = null, string? status = null, string? verified = null)
{
var actorId = await GetCurrentUserIdAsync();
if (!actorId.HasValue)
{
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
try
{
await _accountGovernanceService.SetDisabledAsync(actorId.Value, id, disabled);
TempData["Result"] = disabled ? "Account disabled." : "Account enabled.";
}
catch (InvalidOperationException ex)
{
TempData["Error"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { search, role, status, verified });
}
[HttpPost("{id:guid}/password-reset")]
[Authorize(Policy = "Superuser")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(Guid id, string newPassword, string? search = null, string? role = null, string? status = null, string? verified = null)
{
var actorId = await GetCurrentUserIdAsync();
if (!actorId.HasValue)
{
return RedirectToAction("Login", "Account", new { area = string.Empty });
}
try
{
await _accountGovernanceService.ResetPasswordAsync(actorId.Value, id, newPassword);
TempData["Result"] = "Password reset completed and existing sessions were revoked.";
}
catch (InvalidOperationException ex)
{
TempData["Error"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { search, role, status, verified });
}
private async Task<Guid?> GetCurrentUserIdAsync()
{
var user = await _userManager.GetUserAsync(User);
return user?.Id;
}
private static IReadOnlyList<MemberCenter.Application.Models.Admin.UserGovernanceSummaryDto> ApplyFilters(
IReadOnlyList<MemberCenter.Application.Models.Admin.UserGovernanceSummaryDto> items,
string? role,
string? status,
string? verified)
{
var query = items.AsEnumerable();
query = role switch
{
"superuser" => query.Where(x => x.IsSuperuser),
"admin" => query.Where(x => x.IsAdmin && !x.IsSuperuser),
"member" => query.Where(x => !x.IsAdmin && !x.IsSuperuser),
_ => query
};
query = status switch
{
"disabled" => query.Where(x => x.IsDisabled),
"active" => query.Where(x => !x.IsDisabled),
"blacklisted" => query.Where(x => x.IsBlacklisted),
_ => query
};
query = verified switch
{
"verified" => query.Where(x => x.EmailConfirmed),
"unverified" => query.Where(x => !x.EmailConfirmed),
_ => query
};
return query.ToList();
}
}

View File

@ -1,7 +1,10 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Net.Mail;
namespace MemberCenter.Web.Areas.Admin.Controllers;
@ -11,10 +14,12 @@ namespace MemberCenter.Web.Areas.Admin.Controllers;
public class SecurityController : Controller
{
private readonly ISecuritySettingsService _settingsService;
private readonly UserManager<ApplicationUser> _userManager;
public SecurityController(ISecuritySettingsService settingsService)
public SecurityController(ISecuritySettingsService settingsService, UserManager<ApplicationUser> userManager)
{
_settingsService = settingsService;
_userManager = userManager;
}
[HttpGet("")]
@ -32,8 +37,59 @@ public class SecurityController : Controller
return View("Index", model);
}
await _settingsService.SaveAsync(model);
try
{
await _settingsService.SaveAsync(model, await GetCurrentUserIdAsync());
var saved = await _settingsService.GetAsync();
ViewData["Result"] = "Saved";
return View("Index", saved);
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
return View("Index", model);
}
}
[HttpPost("test-email")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> TestEmail(SecuritySettingsDto model)
{
if (string.IsNullOrWhiteSpace(model.TestToEmail))
{
ModelState.AddModelError(nameof(model.TestToEmail), "Test recipient email is required.");
return View("Index", model);
}
try
{
_ = new MailAddress(model.TestToEmail);
}
catch
{
ModelState.AddModelError(nameof(model.TestToEmail), "Test recipient email is invalid.");
return View("Index", model);
}
try
{
var actorUserId = await GetCurrentUserIdAsync();
await _settingsService.SaveAsync(model, actorUserId);
var sentCount = await _settingsService.SendTestEmailAsync(model.TestToEmail, actorUserId);
var saved = await _settingsService.GetAsync() with { TestToEmail = model.TestToEmail };
ViewData["Result"] = $"SMTP accepted request (sent_count={sentCount}) to {model.TestToEmail}.";
return View("Index", saved);
}
catch (Exception ex)
{
ModelState.AddModelError(string.Empty, $"Test email failed: {ex.Message}");
return View("Index", model);
}
}
private async Task<Guid?> GetCurrentUserIdAsync()
{
var user = await _userManager.GetUserAsync(User);
return user?.Id;
}
}

View File

@ -0,0 +1,13 @@
using MemberCenter.Application.Models.Admin;
namespace MemberCenter.Web.Areas.Admin.Models;
public sealed class AccountsIndexViewModel
{
public string? Search { get; set; }
public string? RoleFilter { get; set; }
public string? StatusFilter { get; set; }
public string? VerifiedFilter { get; set; }
public bool CanManage { get; set; }
public IReadOnlyList<UserGovernanceSummaryDto> Items { get; set; } = Array.Empty<UserGovernanceSummaryDto>();
}

View File

@ -0,0 +1,116 @@
@model MemberCenter.Web.Areas.Admin.Models.AccountsIndexViewModel
<h1>Accounts</h1>
@if (TempData["Result"] is string result)
{
<div class="alert alert-success">@result</div>
}
@if (TempData["Error"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<form method="get" class="mb-3 d-flex gap-2 flex-wrap">
<input type="text" name="search" value="@Model.Search" class="form-control" placeholder="Search by email or profile name" />
<select name="role" class="form-select">
<option value="">All roles</option>
<option value="superuser" selected="@(Model.RoleFilter == "superuser")">Superuser</option>
<option value="admin" selected="@(Model.RoleFilter == "admin")">Admin</option>
<option value="member" selected="@(Model.RoleFilter == "member")">Member</option>
</select>
<select name="status" class="form-select">
<option value="">All statuses</option>
<option value="active" selected="@(Model.StatusFilter == "active")">Active</option>
<option value="disabled" selected="@(Model.StatusFilter == "disabled")">Disabled</option>
<option value="blacklisted" selected="@(Model.StatusFilter == "blacklisted")">Blacklisted</option>
</select>
<select name="verified" class="form-select">
<option value="">All verification</option>
<option value="verified" selected="@(Model.VerifiedFilter == "verified")">Verified</option>
<option value="unverified" selected="@(Model.VerifiedFilter == "unverified")">Unverified</option>
</select>
<button type="submit" class="btn btn-outline-primary">Search</button>
</form>
@if (!Model.CanManage)
{
<div class="alert alert-secondary">This page is visible to admin, but only superuser can change roles or account status.</div>
}
<table class="table table-striped table-sm align-middle">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Verified</th>
<th>Roles</th>
<th>Status</th>
<th>Last Login</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
var fullName = string.Join(" ", new[] { item.LastName, item.FirstName }.Where(x => !string.IsNullOrWhiteSpace(x)));
var roleSummary = item.IsSuperuser ? "superuser" : item.IsAdmin ? "admin" : "member";
var statusSummary = item.IsDisabled ? "disabled" : item.IsBlacklisted ? "blacklisted" : "active";
<tr>
<td>@item.Email</td>
<td>
@(string.IsNullOrWhiteSpace(fullName) ? "-" : fullName)
@if (!string.IsNullOrWhiteSpace(item.NickName))
{
<span class="text-muted">(@item.NickName)</span>
}
</td>
<td>@(item.EmailConfirmed ? "Yes" : "No")</td>
<td>@roleSummary</td>
<td>@statusSummary</td>
<td>@(item.LastLoginAt?.ToString("u") ?? "-")</td>
<td>@item.CreatedAt.ToString("u")</td>
<td>
@if (Model.CanManage && !item.IsSuperuser)
{
<div class="d-flex gap-2 flex-wrap">
<form method="post" asp-action="SetAdmin" asp-route-id="@item.UserId" class="m-0">
@Html.AntiForgeryToken()
<input type="hidden" name="enabled" value="@(item.IsAdmin ? "false" : "true")" />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="role" value="@Model.RoleFilter" />
<input type="hidden" name="status" value="@Model.StatusFilter" />
<input type="hidden" name="verified" value="@Model.VerifiedFilter" />
<button type="submit" class="btn btn-outline-secondary btn-sm">@(item.IsAdmin ? "Remove admin" : "Grant admin")</button>
</form>
<form method="post" asp-action="SetDisabled" asp-route-id="@item.UserId" class="m-0">
@Html.AntiForgeryToken()
<input type="hidden" name="disabled" value="@(item.IsDisabled ? "false" : "true")" />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="role" value="@Model.RoleFilter" />
<input type="hidden" name="status" value="@Model.StatusFilter" />
<input type="hidden" name="verified" value="@Model.VerifiedFilter" />
<button type="submit" class="btn btn-outline-danger btn-sm">@(item.IsDisabled ? "Enable" : "Disable")</button>
</form>
<form method="post" asp-action="ResetPassword" asp-route-id="@item.UserId" class="m-0 d-flex gap-2">
@Html.AntiForgeryToken()
<input type="password" name="newPassword" class="form-control form-control-sm" placeholder="New password" minlength="8" required />
<input type="hidden" name="search" value="@Model.Search" />
<input type="hidden" name="role" value="@Model.RoleFilter" />
<input type="hidden" name="status" value="@Model.StatusFilter" />
<input type="hidden" name="verified" value="@Model.VerifiedFilter" />
<button type="submit" class="btn btn-outline-warning btn-sm">Reset password</button>
</form>
</div>
}
else
{
<span class="text-muted">No actions</span>
}
</td>
</tr>
}
</tbody>
</table>

View File

@ -3,7 +3,7 @@
<h1>Audit Logs</h1>
<table>
<thead>
<tr><th>Action</th><th>Actor</th><th>Time</th></tr>
<tr><th>Action</th><th>Actor</th><th>Payload</th><th>Time</th></tr>
</thead>
<tbody>
@foreach (var log in Model)
@ -11,6 +11,7 @@
<tr>
<td>@log.Action</td>
<td>@log.ActorType @log.ActorId</td>
<td><code>@log.PayloadJson</code></td>
<td>@log.CreatedAt</td>
</tr>
}

View File

@ -1,2 +1,2 @@
<h1>Admin</h1>
<p>Use the admin group in the main navigation to manage tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.</p>
<p>Use the admin group in the main navigation to manage accounts, tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.</p>

View File

@ -5,12 +5,70 @@
{
<p>@ViewData["Result"]</p>
}
<form method="post">
<form asp-action="Save" method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="All"></div>
<label>Access token minutes</label>
<input name="AccessTokenMinutes" value="@Model.AccessTokenMinutes" />
<input asp-for="AccessTokenMinutes" />
<label>Refresh token days</label>
<input name="RefreshTokenDays" value="@Model.RefreshTokenDays" />
<input asp-for="RefreshTokenDays" />
<label asp-for="PublicBaseUrl">Public base URL</label>
<input asp-for="PublicBaseUrl" />
<h2>SMTP</h2>
<label asp-for="SmtpRelayHost">SMTP relay host</label>
<input asp-for="SmtpRelayHost" />
<label asp-for="SmtpRelayPort">SMTP relay port</label>
<input asp-for="SmtpRelayPort" />
<label asp-for="SmtpUseTls">Use TLS</label>
<input asp-for="SmtpUseTls" type="checkbox" />
<label asp-for="SmtpUseSsl">Use SSL</label>
<input asp-for="SmtpUseSsl" type="checkbox" />
<label asp-for="SmtpTimeoutSeconds">SMTP timeout seconds</label>
<input asp-for="SmtpTimeoutSeconds" />
<label asp-for="SmtpUsername">SMTP username</label>
<input asp-for="SmtpUsername" />
<label asp-for="SmtpPassword">SMTP password</label>
<input asp-for="SmtpPassword" type="password" />
@if (Model.HasSmtpPassword)
{
<p>Password saved. Leave blank to keep current password.</p>
}
<label asp-for="SenderName">Sender name</label>
<input asp-for="SenderName" />
<label asp-for="SenderEmail">Sender email</label>
<input asp-for="SenderEmail" />
<button type="submit">Save</button>
</form>
<h2>Test Email</h2>
<form asp-action="TestEmail" method="post">
@Html.AntiForgeryToken()
<input asp-for="AccessTokenMinutes" type="hidden" />
<input asp-for="RefreshTokenDays" type="hidden" />
<input asp-for="PublicBaseUrl" type="hidden" />
<input asp-for="SmtpRelayHost" type="hidden" />
<input asp-for="SmtpRelayPort" type="hidden" />
<input asp-for="SmtpUseTls" type="hidden" />
<input asp-for="SmtpUseSsl" type="hidden" />
<input asp-for="SmtpTimeoutSeconds" type="hidden" />
<input asp-for="SmtpUsername" type="hidden" />
<input asp-for="SmtpPassword" type="hidden" />
<input asp-for="HasSmtpPassword" type="hidden" />
<input asp-for="SenderName" type="hidden" />
<input asp-for="SenderEmail" type="hidden" />
<label asp-for="TestToEmail">Test recipient email</label>
<input asp-for="TestToEmail" />
<button type="submit">Send Test Email</button>
</form>

View File

@ -37,6 +37,7 @@
</div>
<nav class="admin-nav" aria-label="Admin navigation">
<a class="@NavClass("Home")" asp-area="Admin" asp-controller="Home" asp-action="Index">Overview</a>
<a class="@NavClass("Accounts")" asp-area="Admin" asp-controller="Accounts" asp-action="Index">Accounts</a>
<a class="@NavClass("Tenants")" asp-area="Admin" asp-controller="Tenants" asp-action="Index">Tenants</a>
<a class="@NavClass("NewsletterLists")" asp-area="Admin" asp-controller="NewsletterLists" asp-action="Index">Newsletter Lists</a>
<a class="@NavClass("Subscriptions")" asp-area="Admin" asp-controller="Subscriptions" asp-action="Index">Subscriptions</a>

View File

@ -1,25 +1,33 @@
using System.Security.Claims;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Models.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MemberCenter.Web.Controllers;
public class AccountController : Controller
{
private readonly IAccountProvisioningService _accountProvisioningService;
private readonly IAccountEmailService _accountEmailService;
private readonly IAuditLogWriter _auditLogWriter;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AccountController(
IAccountProvisioningService accountProvisioningService,
IAccountEmailService accountEmailService,
IAuditLogWriter auditLogWriter,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_accountProvisioningService = accountProvisioningService;
_accountEmailService = accountEmailService;
_auditLogWriter = auditLogWriter;
_userManager = userManager;
_signInManager = signInManager;
}
@ -31,6 +39,7 @@ public class AccountController : Controller
}
[HttpPost]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthLogin)]
public async Task<IActionResult> Login(LoginViewModel model)
{
if (!ModelState.IsValid)
@ -38,13 +47,31 @@ public class AccountController : Controller
return View(model);
}
var loginUser = await _userManager.FindByEmailAsync(model.Email);
if (loginUser?.DisabledAt.HasValue == true)
{
ModelState.AddModelError(string.Empty, "Account is disabled.");
return View(model);
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, false, true);
if (!result.Succeeded)
{
if (result.IsLockedOut)
{
ModelState.AddModelError(string.Empty, "Account is temporarily locked. Please try again later.");
return View(model);
}
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
if (loginUser is not null)
{
await UpdateSignInMetadataAsync(loginUser);
}
if (!string.IsNullOrWhiteSpace(model.ReturnUrl))
{
return LocalRedirect(model.ReturnUrl);
@ -103,7 +130,14 @@ public class AccountController : Controller
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
if (user.DisabledAt.HasValue)
{
ModelState.AddModelError(string.Empty, "Account is disabled.");
return View("Login", new LoginViewModel { ReturnUrl = returnUrl });
}
await _signInManager.SignInAsync(user, false, info.LoginProvider);
await UpdateSignInMetadataAsync(user);
if (!string.IsNullOrWhiteSpace(returnUrl))
{
@ -155,6 +189,11 @@ public class AccountController : Controller
}
await _signInManager.RefreshSignInAsync(user);
await _auditLogWriter.WriteAsync("user", user.Id, "account.password_changed", new
{
user_id = user.Id,
email = user.Email
});
ViewData["Result"] = "Password updated.";
ModelState.Clear();
return View(new ChangePasswordViewModel());
@ -167,6 +206,7 @@ public class AccountController : Controller
}
[HttpPost]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
public async Task<IActionResult> Register(RegisterViewModel model)
{
if (!ModelState.IsValid)
@ -184,7 +224,13 @@ public class AccountController : Controller
return View(model);
}
return RedirectToAction("Login");
var user = await _userManager.FindByEmailAsync(model.Email);
if (user is not null)
{
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
}
return View("RegisterConfirmation");
}
[HttpGet]
@ -194,6 +240,7 @@ public class AccountController : Controller
}
[HttpPost]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
if (!ModelState.IsValid)
@ -207,9 +254,7 @@ public class AccountController : Controller
return View("ForgotPasswordConfirmation");
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
ViewData["ResetToken"] = token;
ViewData["ResetEmail"] = user.Email;
await _accountEmailService.SendPasswordResetEmailAsync(user.Id, GetBaseUrl());
return View("ForgotPasswordConfirmation");
}
@ -243,6 +288,11 @@ public class AccountController : Controller
return View(model);
}
await _auditLogWriter.WriteAsync("user", user.Id, "account.password_reset_completed", new
{
user_id = user.Id,
email = user.Email
});
return View("ResetPasswordConfirmation");
}
@ -256,6 +306,44 @@ public class AccountController : Controller
}
var result = await _userManager.ConfirmEmailAsync(user, token);
if (result.Succeeded)
{
await _auditLogWriter.WriteAsync("user", user.Id, "account.email_verified", new
{
user_id = user.Id,
email = user.Email
});
}
return View("VerifyEmailResult", result.Succeeded);
}
[Authorize]
[HttpPost]
[ValidateAntiForgeryToken]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
public async Task<IActionResult> ResendVerification()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction(nameof(Login));
}
if (!user.EmailConfirmed)
{
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
TempData["Result"] = "Verification email sent.";
}
return RedirectToAction("Index", "Profile");
}
private string GetBaseUrl() => $"{Request.Scheme}://{Request.Host}{Request.PathBase}";
private async Task UpdateSignInMetadataAsync(ApplicationUser user)
{
user.LastLoginAt = DateTimeOffset.UtcNow;
user.LastSeenAt = user.LastLoginAt;
await _userManager.UpdateAsync(user);
}
}

View File

@ -1,4 +1,7 @@
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Profile;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Models.Profile;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -8,10 +11,17 @@ namespace MemberCenter.Web.Controllers;
[Authorize]
public class ProfileController : Controller
{
private readonly IProfileService _profileService;
private readonly INewsletterService _newsletterService;
private readonly UserManager<ApplicationUser> _userManager;
public ProfileController(UserManager<ApplicationUser> userManager)
public ProfileController(
IProfileService profileService,
INewsletterService newsletterService,
UserManager<ApplicationUser> userManager)
{
_profileService = profileService;
_newsletterService = newsletterService;
_userManager = userManager;
}
@ -24,6 +34,217 @@ public class ProfileController : Controller
return RedirectToAction("Login", "Account");
}
return View(user);
var profile = await _profileService.GetProfileAsync(user.Id);
return View(MapProfile(profile));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Index(ProfileViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
}
try
{
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
model.LastName,
model.FirstName,
model.NickName,
model.MobilePhone,
model.LandlinePhone,
model.DateOfBirth,
model.Gender,
model.CompanyName,
model.Department,
model.JobTitle,
model.CompanyPhone,
model.TaxId,
model.InvoiceTitle,
model.Remark));
ViewData["Result"] = "Saved";
return View(MapProfile(profile));
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
return View(model);
}
}
[HttpGet("profile/addresses")]
public async Task<IActionResult> Addresses(Guid? id = null)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
}
var addresses = await _profileService.ListAddressesAsync(user.Id);
var form = new AddressFormViewModel();
if (id.HasValue)
{
var address = await _profileService.GetAddressAsync(user.Id, id.Value);
if (address is not null)
{
form = MapAddress(address);
}
}
return View(new AddressesPageViewModel
{
Addresses = addresses,
Form = form
});
}
[HttpPost("profile/addresses")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveAddress(AddressFormViewModel model)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
}
if (!ModelState.IsValid)
{
return View("Addresses", new AddressesPageViewModel
{
Addresses = await _profileService.ListAddressesAsync(user.Id),
Form = model
});
}
try
{
await _profileService.SaveAddressAsync(user.Id, new SaveUserAddressRequest(
model.Id,
model.Label,
model.RecipientName,
model.RecipientPhone,
model.CountryCode,
model.PostalCode,
model.StateRegion,
model.City,
model.District,
model.AddressLine1,
model.AddressLine2,
model.CompanyName,
model.Usage,
model.IsDefault,
model.AddressMetaJson));
return RedirectToAction(nameof(Addresses));
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
return View("Addresses", new AddressesPageViewModel
{
Addresses = await _profileService.ListAddressesAsync(user.Id),
Form = model
});
}
}
[HttpPost("profile/addresses/{id:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteAddress(Guid id)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
}
try
{
await _profileService.DeleteAddressAsync(user.Id, id);
return RedirectToAction(nameof(Addresses));
}
catch (InvalidOperationException ex)
{
TempData["Error"] = ex.Message;
return RedirectToAction(nameof(Addresses));
}
}
[HttpGet("profile/subscriptions")]
public async Task<IActionResult> Subscriptions()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
}
return View(new SubscriptionsPageViewModel
{
Subscriptions = await _newsletterService.ListSubscriptionsForUserAsync(user.Id)
});
}
[HttpPost("profile/subscriptions/{id:guid}/unsubscribe")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Unsubscribe(Guid id)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return RedirectToAction("Login", "Account");
}
await _newsletterService.UnsubscribeForUserAsync(user.Id, id);
return RedirectToAction(nameof(Subscriptions));
}
private static ProfileViewModel MapProfile(UserProfileDto profile) =>
new()
{
Email = profile.Email,
LastName = profile.LastName,
FirstName = profile.FirstName,
NickName = profile.NickName,
MobilePhone = profile.MobilePhone,
LandlinePhone = profile.LandlinePhone,
DateOfBirth = profile.DateOfBirth,
Gender = profile.Gender,
CompanyName = profile.CompanyName,
Department = profile.Department,
JobTitle = profile.JobTitle,
CompanyPhone = profile.CompanyPhone,
TaxId = profile.TaxId,
InvoiceTitle = profile.InvoiceTitle,
Remark = profile.Remark
};
private static AddressFormViewModel MapAddress(UserAddressDto address) =>
new()
{
Id = address.Id,
Label = address.Label,
RecipientName = address.RecipientName,
RecipientPhone = address.RecipientPhone,
CountryCode = address.CountryCode,
PostalCode = address.PostalCode,
StateRegion = address.StateRegion,
City = address.City,
District = address.District,
AddressLine1 = address.AddressLine1,
AddressLine2 = address.AddressLine2,
CompanyName = address.CompanyName,
Usage = address.Usage,
IsDefault = address.IsDefault,
AddressMetaJson = address.AddressMetaJson
};
}

View File

@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Profile;
public sealed class AddressFormViewModel
{
public Guid? Id { get; set; }
[Required]
[StringLength(100)]
public string Label { get; set; } = "home";
[Required]
[StringLength(100)]
public string RecipientName { get; set; } = string.Empty;
[Required]
[StringLength(50)]
public string RecipientPhone { get; set; } = string.Empty;
[Required]
[StringLength(2, MinimumLength = 2)]
public string CountryCode { get; set; } = "TW";
[StringLength(20)]
public string? PostalCode { get; set; }
[StringLength(100)]
public string? StateRegion { get; set; }
[StringLength(100)]
public string? City { get; set; }
[StringLength(100)]
public string? District { get; set; }
[Required]
[StringLength(255)]
public string AddressLine1 { get; set; } = string.Empty;
[StringLength(255)]
public string? AddressLine2 { get; set; }
[StringLength(200)]
public string? CompanyName { get; set; }
[Required]
public string Usage { get; set; } = "shipping";
public bool IsDefault { get; set; } = true;
public string? AddressMetaJson { get; set; }
}

View File

@ -0,0 +1,9 @@
using MemberCenter.Application.Models.Profile;
namespace MemberCenter.Web.Models.Profile;
public sealed class AddressesPageViewModel
{
public IReadOnlyList<UserAddressDto> Addresses { get; set; } = Array.Empty<UserAddressDto>();
public AddressFormViewModel Form { get; set; } = new();
}

View File

@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
namespace MemberCenter.Web.Models.Profile;
public sealed class ProfileViewModel
{
[Required]
[StringLength(100)]
public string LastName { get; set; } = string.Empty;
[Required]
[StringLength(100)]
public string FirstName { get; set; } = string.Empty;
[StringLength(100)]
public string? NickName { get; set; }
[StringLength(50)]
public string? MobilePhone { get; set; }
[StringLength(50)]
public string? LandlinePhone { get; set; }
[DataType(DataType.Date)]
public DateOnly? DateOfBirth { get; set; }
[Required]
public string Gender { get; set; } = "unspecified";
[StringLength(200)]
public string? CompanyName { get; set; }
[StringLength(200)]
public string? Department { get; set; }
[StringLength(200)]
public string? JobTitle { get; set; }
[StringLength(50)]
public string? CompanyPhone { get; set; }
[StringLength(32)]
public string? TaxId { get; set; }
[StringLength(200)]
public string? InvoiceTitle { get; set; }
[StringLength(1000)]
public string? Remark { get; set; }
public string Email { get; set; } = string.Empty;
}

View File

@ -0,0 +1,8 @@
using MemberCenter.Application.Models.Profile;
namespace MemberCenter.Web.Models.Profile;
public sealed class SubscriptionsPageViewModel
{
public IReadOnlyList<UserSubscriptionSummaryDto> Subscriptions { get; set; } = Array.Empty<UserSubscriptionSummaryDto>();
}

View File

@ -1,6 +1,11 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
@ -31,10 +36,18 @@ builder.Services
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.Lockout.AllowedForNewUsers = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
builder.Services.Configure<SecurityStampValidatorOptions>(options =>
{
options.ValidationInterval = TimeSpan.Zero;
});
var googleClientId = builder.Configuration["Authentication:Google:ClientId"]
?? Environment.GetEnvironmentVariable("Authentication__Google__ClientId");
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]
@ -56,13 +69,38 @@ builder.Services.ConfigureApplicationCookie(options =>
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = context => HandleAdminAuthRedirectAsync(context),
OnRedirectToAccessDenied = context => HandleAdminAuthRedirectAsync(context)
OnRedirectToAccessDenied = context => HandleAdminAuthRedirectAsync(context),
OnValidatePrincipal = context => ValidatePrincipalAsync(context)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Admin", policy => policy.RequireRole("admin"));
options.AddPolicy("Admin", policy => policy.RequireRole("admin", "superuser"));
options.AddPolicy("Superuser", policy => policy.RequireRole("superuser"));
});
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = static async (context, token) =>
{
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = Math.Ceiling(retryAfter.TotalSeconds).ToString();
}
await context.HttpContext.Response.WriteAsync("Too many requests.", token);
};
options.AddPolicy(RateLimitPolicyNames.PublicAuthLogin, context =>
CreateFixedWindowLimiter(context, "web-auth-login", permitLimit: 10, TimeSpan.FromMinutes(5)));
options.AddPolicy(RateLimitPolicyNames.PublicAuthRegister, context =>
CreateFixedWindowLimiter(context, "web-auth-register", permitLimit: 5, TimeSpan.FromMinutes(15)));
options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context =>
CreateFixedWindowLimiter(context, "web-auth-recovery", permitLimit: 5, TimeSpan.FromMinutes(15)));
});
builder.Services.AddScoped<INewsletterService, NewsletterService>();
@ -70,9 +108,15 @@ builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<IAuditLogWriter, AuditLogWriter>();
builder.Services.AddScoped<IAccountGovernanceService, AccountGovernanceService>();
builder.Services.AddScoped<IAccountEmailTemplateService, AccountEmailTemplateService>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IAccountEmailService, AccountEmailService>();
builder.Services.AddScoped<ISecuritySettingsService, SecuritySettingsService>();
builder.Services.AddScoped<ISubscriptionAdminService, SubscriptionAdminService>();
builder.Services.AddScoped<IAccountProvisioningService, AccountProvisioningService>();
builder.Services.AddScoped<IProfileService, ProfileService>();
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
@ -85,6 +129,7 @@ builder.Services.AddOpenIddict()
});
builder.Services.AddControllersWithViews();
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
@ -95,6 +140,7 @@ if (!app.Environment.IsDevelopment())
}
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
@ -120,3 +166,44 @@ static Task HandleAdminAuthRedirectAsync(RedirectContext<CookieAuthenticationOpt
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
}
static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context)
{
var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
if (!Guid.TryParse(userId, out var parsedUserId))
{
return;
}
var userManager = context.HttpContext.RequestServices.GetRequiredService<UserManager<ApplicationUser>>();
var user = await userManager.FindByIdAsync(parsedUserId.ToString());
if (user is null || user.DisabledAt.HasValue)
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync();
}
}
static RateLimitPartition<string> CreateFixedWindowLimiter(
HttpContext context,
string policyPrefix,
int permitLimit,
TimeSpan window)
{
var identifier = context.User.Identity?.IsAuthenticated == true
? context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.Identity?.Name
?? context.Connection.RemoteIpAddress?.ToString()
?? "unknown"
: context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var partitionKey = $"{policyPrefix}:{identifier}";
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = window,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
AutoReplenishment = true
});
}

View File

@ -1,16 +1,2 @@
@{
var token = ViewData["ResetToken"] as string;
var email = ViewData["ResetEmail"] as string;
}
<h1>Reset Token</h1>
@if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(email))
{
<p>Use this token for reset:</p>
<p><strong>@token</strong></p>
<p><a href="/account/resetpassword?email=@email&token=@token">Go to reset</a></p>
}
else
{
<p>If the email exists, a reset token has been generated.</p>
}
<h1>Password Reset</h1>
<p>If the email exists, a password reset email has been sent.</p>

View File

@ -0,0 +1,2 @@
<h1>Registration Complete</h1>
<p>Your account has been created. Please check your email for the verification link.</p>

View File

@ -0,0 +1,85 @@
@model MemberCenter.Web.Models.Profile.AddressesPageViewModel
<h1>Addresses</h1>
@if (TempData["Error"] is string error)
{
<p>@error</p>
}
<h2>Saved Addresses</h2>
@if (!Model.Addresses.Any())
{
<p>No addresses yet.</p>
}
else
{
<table>
<thead>
<tr>
<th>Label</th>
<th>Recipient</th>
<th>Usage</th>
<th>Default</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var address in Model.Addresses)
{
<tr>
<td>@address.Label</td>
<td>@address.RecipientName</td>
<td>@address.Usage</td>
<td>@(address.IsDefault ? "Yes" : "No")</td>
<td>
<a asp-action="Addresses" asp-route-id="@address.Id">Edit</a>
<form asp-action="DeleteAddress" asp-route-id="@address.Id" method="post" style="display:inline">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
</form>
</td>
</tr>
}
</tbody>
</table>
}
<h2>@(Model.Form.Id.HasValue ? "Edit Address" : "Add Address")</h2>
<form asp-action="SaveAddress" method="post">
@Html.AntiForgeryToken()
<input asp-for="Form.Id" type="hidden" />
<div asp-validation-summary="All"></div>
<label asp-for="Form.Label"></label>
<input asp-for="Form.Label" />
<label asp-for="Form.RecipientName"></label>
<input asp-for="Form.RecipientName" />
<label asp-for="Form.RecipientPhone"></label>
<input asp-for="Form.RecipientPhone" />
<label asp-for="Form.CountryCode"></label>
<input asp-for="Form.CountryCode" />
<label asp-for="Form.PostalCode"></label>
<input asp-for="Form.PostalCode" />
<label asp-for="Form.StateRegion"></label>
<input asp-for="Form.StateRegion" />
<label asp-for="Form.City"></label>
<input asp-for="Form.City" />
<label asp-for="Form.District"></label>
<input asp-for="Form.District" />
<label asp-for="Form.AddressLine1"></label>
<input asp-for="Form.AddressLine1" />
<label asp-for="Form.AddressLine2"></label>
<input asp-for="Form.AddressLine2" />
<label asp-for="Form.CompanyName"></label>
<input asp-for="Form.CompanyName" />
<label asp-for="Form.Usage"></label>
<select asp-for="Form.Usage">
<option value="shipping">shipping</option>
<option value="billing">billing</option>
<option value="both">both</option>
</select>
<label asp-for="Form.IsDefault"></label>
<input asp-for="Form.IsDefault" type="checkbox" />
<label asp-for="Form.AddressMetaJson"></label>
<textarea asp-for="Form.AddressMetaJson"></textarea>
<button type="submit">Save</button>
</form>

View File

@ -1,7 +1,59 @@
@model MemberCenter.Infrastructure.Identity.ApplicationUser
@model MemberCenter.Web.Models.Profile.ProfileViewModel
<h1>Profile</h1>
<p>Email: @Model.Email</p>
<p>Verified: @Model.EmailConfirmed</p>
<p>Created: @Model.CreatedAt</p>
@if (ViewData["Result"] is not null)
{
<p>@ViewData["Result"]</p>
}
@if (TempData["Result"] is string result)
{
<p>@result</p>
}
<form method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="All"></div>
<p>Email: @Model.Email</p>
<label asp-for="LastName"></label>
<input asp-for="LastName" />
<label asp-for="FirstName"></label>
<input asp-for="FirstName" />
<label asp-for="NickName"></label>
<input asp-for="NickName" />
<label asp-for="MobilePhone"></label>
<input asp-for="MobilePhone" />
<label asp-for="LandlinePhone"></label>
<input asp-for="LandlinePhone" />
<label asp-for="DateOfBirth"></label>
<input asp-for="DateOfBirth" />
<label asp-for="Gender"></label>
<select asp-for="Gender">
<option value="unspecified">unspecified</option>
<option value="male">male</option>
<option value="female">female</option>
<option value="other">other</option>
</select>
<label asp-for="CompanyName"></label>
<input asp-for="CompanyName" />
<label asp-for="Department"></label>
<input asp-for="Department" />
<label asp-for="JobTitle"></label>
<input asp-for="JobTitle" />
<label asp-for="CompanyPhone"></label>
<input asp-for="CompanyPhone" />
<label asp-for="TaxId"></label>
<input asp-for="TaxId" />
<label asp-for="InvoiceTitle"></label>
<input asp-for="InvoiceTitle" />
<label asp-for="Remark"></label>
<textarea asp-for="Remark"></textarea>
<button type="submit">Save</button>
</form>
<p><a asp-controller="Account" asp-action="ChangePassword">Change Password</a></p>
<form asp-controller="Account" asp-action="ResendVerification" method="post">
@Html.AntiForgeryToken()
<button type="submit">Resend Verification Email</button>
</form>
<p><a asp-action="Addresses">Manage Addresses</a></p>
<p><a asp-action="Subscriptions">Manage Subscriptions</a></p>

View File

@ -0,0 +1,43 @@
@model MemberCenter.Web.Models.Profile.SubscriptionsPageViewModel
<h1>My Subscriptions</h1>
@if (!Model.Subscriptions.Any())
{
<p>No subscriptions linked to this account.</p>
}
else
{
<table>
<thead>
<tr>
<th>Tenant</th>
<th>List</th>
<th>Status</th>
<th>Email</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var subscription in Model.Subscriptions)
{
<tr>
<td>@subscription.TenantName</td>
<td>@subscription.ListName</td>
<td>@subscription.Status</td>
<td>@subscription.Email</td>
<td>@subscription.CreatedAt</td>
<td>
@if (!string.Equals(subscription.Status, "unsubscribed", StringComparison.OrdinalIgnoreCase))
{
<form asp-action="Unsubscribe" asp-route-id="@subscription.Id" method="post">
@Html.AntiForgeryToken()
<button type="submit">Unsubscribe</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
}

View File

@ -39,12 +39,13 @@
<a asp-area="" asp-controller="Newsletter" asp-action="Preferences">Preferences</a>
</div>
</div>
@if (User.IsInRole("admin"))
@if (User.IsInRole("admin") || User.IsInRole("superuser"))
{
<div class="d-flex flex-column">
<span class="text-muted small text-uppercase">Admin</span>
<div class="d-flex gap-3 flex-wrap">
<a asp-area="Admin" asp-controller="Home" asp-action="Index">Overview</a>
<a asp-area="Admin" asp-controller="Accounts" asp-action="Index">Accounts</a>
<a asp-area="Admin" asp-controller="Tenants" asp-action="Index">Tenants</a>
<a asp-area="Admin" asp-controller="NewsletterLists" asp-action="Index">Newsletter Lists</a>
<a asp-area="Admin" asp-controller="Subscriptions" asp-action="Index">Subscriptions</a>