From c9c0396ad2f47652fe45079786782b35ff7e460d Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Fri, 17 Apr 2026 03:46:32 +0900 Subject: [PATCH] Implement member profile, email flows, and account governance --- docs/INSTALL.md | 20 +- docs/MEMBER_UPGRADE_PLAN.md | 227 +++- .../Contracts/ProfileRequests.cs | 34 + .../AdminOAuthClientsController.cs | 12 + .../Controllers/AuthController.cs | 51 +- .../Controllers/NewsletterController.cs | 4 + .../Controllers/TokenController.cs | 19 +- .../Controllers/UserController.cs | 285 ++++- .../Extensions/ClaimsExtensions.cs | 8 + src/MemberCenter.Api/Program.cs | 112 +- .../Abstractions/IAccountEmailService.cs | 7 + .../IAccountEmailTemplateService.cs | 9 + .../Abstractions/IAccountGovernanceService.cs | 11 + .../Abstractions/IAuditLogWriter.cs | 6 + .../Abstractions/IEmailSender.cs | 6 + .../Abstractions/INewsletterService.cs | 5 + .../Abstractions/IProfileService.cs | 13 + .../Abstractions/ISecuritySettingsService.cs | 3 +- .../Constants/RateLimitPolicyNames.cs | 10 + .../Models/Admin/AuditLogDto.cs | 1 + .../Models/Admin/SecuritySettingsDto.cs | 14 +- .../Models/Admin/SmtpSettingsDto.cs | 14 + .../Models/Admin/UserGovernanceSummaryDto.cs | 18 + .../Models/Email/EmailTemplate.cs | 6 + .../Models/Profile/SaveUserAddressRequest.cs | 18 + .../Models/Profile/SaveUserProfileRequest.cs | 17 + .../Models/Profile/UserAddressDto.cs | 19 + .../Models/Profile/UserProfileDto.cs | 19 + .../Profile/UserSubscriptionSummaryDto.cs | 11 + .../Entities/UserAddress.cs | 25 + .../Entities/UserProfile.cs | 21 + .../Identity/ApplicationUser.cs | 8 + .../Persistence/MemberCenterDbContext.cs | 63 + ...22_AddUserProfilesAndAddresses.Designer.cs | 1070 +++++++++++++++++ ...60416161322_AddUserProfilesAndAddresses.cs | 159 +++ .../MemberCenterDbContextModelSnapshot.cs | 211 ++++ .../Services/AccountEmailService.cs | 88 ++ .../Services/AccountEmailTemplateService.cs | 23 + .../Services/AccountGovernanceService.cs | 236 ++++ .../Services/AccountProvisioningService.cs | 38 + .../Services/AuditLogService.cs | 2 +- .../Services/AuditLogWriter.cs | 29 + .../Services/NewsletterService.cs | 117 ++ .../Services/ProfileService.cs | 340 ++++++ .../Services/SecuritySettingsService.cs | 169 ++- .../Services/SmtpEmailSender.cs | 72 ++ src/MemberCenter.Installer/Program.cs | 39 +- .../Admin/Controllers/AccountsController.cs | 154 +++ .../Admin/Controllers/SecurityController.cs | 64 +- .../Admin/Models/AccountsIndexViewModel.cs | 13 + .../Areas/Admin/Views/Accounts/Index.cshtml | 116 ++ .../Areas/Admin/Views/AuditLogs/Index.cshtml | 3 +- .../Areas/Admin/Views/Home/Index.cshtml | 2 +- .../Areas/Admin/Views/Security/Index.cshtml | 64 +- .../Areas/Admin/Views/Shared/_Layout.cshtml | 1 + .../Controllers/AccountController.cs | 96 +- .../Controllers/ProfileController.cs | 225 +++- .../Models/Profile/AddressFormViewModel.cs | 53 + .../Models/Profile/AddressesPageViewModel.cs | 9 + .../Models/Profile/ProfileViewModel.cs | 52 + .../Profile/SubscriptionsPageViewModel.cs | 8 + src/MemberCenter.Web/Program.cs | 91 +- .../Account/ForgotPasswordConfirmation.cshtml | 18 +- .../Views/Account/RegisterConfirmation.cshtml | 2 + .../Views/Profile/Addresses.cshtml | 85 ++ .../Views/Profile/Index.cshtml | 60 +- .../Views/Profile/Subscriptions.cshtml | 43 + .../Views/Shared/_Layout.cshtml | 3 +- 68 files changed, 4749 insertions(+), 102 deletions(-) create mode 100644 src/MemberCenter.Api/Contracts/ProfileRequests.cs create mode 100644 src/MemberCenter.Application/Abstractions/IAccountEmailService.cs create mode 100644 src/MemberCenter.Application/Abstractions/IAccountEmailTemplateService.cs create mode 100644 src/MemberCenter.Application/Abstractions/IAccountGovernanceService.cs create mode 100644 src/MemberCenter.Application/Abstractions/IAuditLogWriter.cs create mode 100644 src/MemberCenter.Application/Abstractions/IEmailSender.cs create mode 100644 src/MemberCenter.Application/Abstractions/IProfileService.cs create mode 100644 src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs create mode 100644 src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs create mode 100644 src/MemberCenter.Application/Models/Admin/UserGovernanceSummaryDto.cs create mode 100644 src/MemberCenter.Application/Models/Email/EmailTemplate.cs create mode 100644 src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs create mode 100644 src/MemberCenter.Application/Models/Profile/SaveUserProfileRequest.cs create mode 100644 src/MemberCenter.Application/Models/Profile/UserAddressDto.cs create mode 100644 src/MemberCenter.Application/Models/Profile/UserProfileDto.cs create mode 100644 src/MemberCenter.Application/Models/Profile/UserSubscriptionSummaryDto.cs create mode 100644 src/MemberCenter.Domain/Entities/UserAddress.cs create mode 100644 src/MemberCenter.Domain/Entities/UserProfile.cs create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs create mode 100644 src/MemberCenter.Infrastructure/Services/AccountEmailService.cs create mode 100644 src/MemberCenter.Infrastructure/Services/AccountEmailTemplateService.cs create mode 100644 src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs create mode 100644 src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs create mode 100644 src/MemberCenter.Infrastructure/Services/ProfileService.cs create mode 100644 src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs create mode 100644 src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs create mode 100644 src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs create mode 100644 src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml create mode 100644 src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs create mode 100644 src/MemberCenter.Web/Models/Profile/AddressesPageViewModel.cs create mode 100644 src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs create mode 100644 src/MemberCenter.Web/Models/Profile/SubscriptionsPageViewModel.cs create mode 100644 src/MemberCenter.Web/Views/Account/RegisterConfirmation.cshtml create mode 100644 src/MemberCenter.Web/Views/Profile/Addresses.cshtml create mode 100644 src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 89e9691..7aef51d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -82,12 +82,12 @@ SendEngine__WebhookSecret=change-me - 若 appsettings 中缺少連線字串,會互動式詢問並寫入 - 若設定環境變數,會優先使用環境變數(不寫入 appsettings) 2) 執行 migrations(不 Drop) -3) 建立 roles(admin, support) -4) 建立 admin(不存在才建立)並加入 admin 角色 +3) 建立 roles(superuser, admin, support) +4) 建立使用者(不存在才建立)並加入 `superuser` 角色 5) 寫入安裝鎖定(DB flag: `system_flags` / `installed=true`) -### 2) `installer add-admin` -用途:新增 superuser +### 2) `installer add-superuser` +用途:新增或提升 superuser 參數: - `--admin-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 ` @@ -109,6 +112,9 @@ SendEngine__WebhookSecret=change-me 1) 解析連線字串 2) 更新密碼(強制) +相容性: +- 舊指令 `installer reset-admin-password` 仍保留為 alias,目前語意等同 `installer reset-superuser-password` + ### 4) `installer migrate` 用途:只執行 migrations diff --git a/docs/MEMBER_UPGRADE_PLAN.md b/docs/MEMBER_UPGRADE_PLAN.md index 1d8bee5..fac9e3d 100644 --- a/docs/MEMBER_UPGRADE_PLAN.md +++ b/docs/MEMBER_UPGRADE_PLAN.md @@ -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: + - Web:login / register / forgot password / resend verification + - API:register / forgot password / resend verification + - Newsletter API:public 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 已有 lockout;service 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 diff --git a/src/MemberCenter.Api/Contracts/ProfileRequests.cs b/src/MemberCenter.Api/Contracts/ProfileRequests.cs new file mode 100644 index 0000000..73c062a --- /dev/null +++ b/src/MemberCenter.Api/Contracts/ProfileRequests.cs @@ -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); diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index 4a523d1..d1f21d2 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -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"); + } } diff --git a/src/MemberCenter.Api/Controllers/AuthController.cs b/src/MemberCenter.Api/Controllers/AuthController.cs index 1c68ea1..d8557ef 100644 --- a/src/MemberCenter.Api/Controllers/AuthController.cs +++ b/src/MemberCenter.Api/Controllers/AuthController.cs @@ -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 _userManager; private readonly SignInManager _signInManager; public AuthController( IAccountProvisioningService accountProvisioningService, + IAccountEmailService accountEmailService, + IAuditLogWriter auditLogWriter, UserManager userManager, SignInManager signInManager) { _accountProvisioningService = accountProvisioningService; + _accountEmailService = accountEmailService; + _auditLogWriter = auditLogWriter; _userManager = userManager; _signInManager = signInManager; } [HttpPost("register")] + [EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)] public async Task 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 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 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 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}"; } diff --git a/src/MemberCenter.Api/Controllers/NewsletterController.cs b/src/MemberCenter.Api/Controllers/NewsletterController.cs index 9093382..62189e0 100644 --- a/src/MemberCenter.Api/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Api/Controllers/NewsletterController.cs @@ -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 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 IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request) { if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email)) diff --git a/src/MemberCenter.Api/Controllers/TokenController.cs b/src/MemberCenter.Api/Controllers/TokenController.cs index 3df6dde..79631b2 100644 --- a/src/MemberCenter.Api/Controllers/TokenController.cs +++ b/src/MemberCenter.Api/Controllers/TokenController.cs @@ -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 _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)) diff --git a/src/MemberCenter.Api/Controllers/UserController.cs b/src/MemberCenter.Api/Controllers/UserController.cs index dbba210..c4314dd 100644 --- a/src/MemberCenter.Api/Controllers/UserController.cs +++ b/src/MemberCenter.Api/Controllers/UserController.cs @@ -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 _userManager; - public UserController(UserManager userManager) + public UserController( + IProfileService profileService, + INewsletterService newsletterService, + UserManager userManager) { + _profileService = profileService; + _newsletterService = newsletterService; _userManager = userManager; } - [Authorize] + [Authorize(Policy = "ProfileBasicRead")] [HttpGet("profile")] public async Task 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 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 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 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 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 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 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 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 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 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 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 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 SubscriptionsByEmail([FromQuery] string email) + { + return Ok(await _newsletterService.ListSubscriptionsByEmailAsync(email)); + } + + [Authorize(Policy = "ProfileSubscriptionsWrite")] + [HttpPost("subscriptions/by-email/{id:guid}/unsubscribe")] + public async Task UnsubscribeByEmail(Guid id, [FromQuery] string email) + { + var subscription = await _newsletterService.UnsubscribeByEmailAsync(email, id); + return subscription is null ? NotFound() : Ok(subscription); + } } diff --git a/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs b/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs index 2a8fc62..0a561d0 100644 --- a/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs +++ b/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs @@ -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); + } } diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 52c628c..69df521 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -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() .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(options => @@ -119,12 +140,45 @@ builder.Services.Configure(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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpContextAccessor(); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); @@ -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>(); + 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 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 + }); +} diff --git a/src/MemberCenter.Application/Abstractions/IAccountEmailService.cs b/src/MemberCenter.Application/Abstractions/IAccountEmailService.cs new file mode 100644 index 0000000..56d4988 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAccountEmailService.cs @@ -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); +} diff --git a/src/MemberCenter.Application/Abstractions/IAccountEmailTemplateService.cs b/src/MemberCenter.Application/Abstractions/IAccountEmailTemplateService.cs new file mode 100644 index 0000000..141d2db --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAccountEmailTemplateService.cs @@ -0,0 +1,9 @@ +using MemberCenter.Application.Models.Email; + +namespace MemberCenter.Application.Abstractions; + +public interface IAccountEmailTemplateService +{ + EmailTemplate BuildVerificationEmail(string verifyUrl); + EmailTemplate BuildPasswordResetEmail(string resetUrl); +} diff --git a/src/MemberCenter.Application/Abstractions/IAccountGovernanceService.cs b/src/MemberCenter.Application/Abstractions/IAccountGovernanceService.cs new file mode 100644 index 0000000..5369121 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAccountGovernanceService.cs @@ -0,0 +1,11 @@ +using MemberCenter.Application.Models.Admin; + +namespace MemberCenter.Application.Abstractions; + +public interface IAccountGovernanceService +{ + Task> 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); +} diff --git a/src/MemberCenter.Application/Abstractions/IAuditLogWriter.cs b/src/MemberCenter.Application/Abstractions/IAuditLogWriter.cs new file mode 100644 index 0000000..ab557f4 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IAuditLogWriter.cs @@ -0,0 +1,6 @@ +namespace MemberCenter.Application.Abstractions; + +public interface IAuditLogWriter +{ + Task WriteAsync(string actorType, Guid? actorId, string action, object payload); +} diff --git a/src/MemberCenter.Application/Abstractions/IEmailSender.cs b/src/MemberCenter.Application/Abstractions/IEmailSender.cs new file mode 100644 index 0000000..71482b3 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IEmailSender.cs @@ -0,0 +1,6 @@ +namespace MemberCenter.Application.Abstractions; + +public interface IEmailSender +{ + Task SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null); +} diff --git a/src/MemberCenter.Application/Abstractions/INewsletterService.cs b/src/MemberCenter.Application/Abstractions/INewsletterService.cs index e88057c..1d945fa 100644 --- a/src/MemberCenter.Application/Abstractions/INewsletterService.cs +++ b/src/MemberCenter.Application/Abstractions/INewsletterService.cs @@ -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 GetPreferencesAsync(Guid listId, string email); Task UpdatePreferencesAsync(Guid listId, string email, Dictionary preferences); Task> ListSubscriptionsAsync(Guid listId); + Task> ListSubscriptionsForUserAsync(Guid userId); + Task UnsubscribeForUserAsync(Guid userId, Guid subscriptionId); + Task> ListSubscriptionsByEmailAsync(string email); + Task UnsubscribeByEmailAsync(string email, Guid subscriptionId); } diff --git a/src/MemberCenter.Application/Abstractions/IProfileService.cs b/src/MemberCenter.Application/Abstractions/IProfileService.cs new file mode 100644 index 0000000..9dddfbc --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IProfileService.cs @@ -0,0 +1,13 @@ +using MemberCenter.Application.Models.Profile; + +namespace MemberCenter.Application.Abstractions; + +public interface IProfileService +{ + Task GetProfileAsync(Guid userId); + Task SaveProfileAsync(Guid userId, SaveUserProfileRequest request); + Task> ListAddressesAsync(Guid userId); + Task GetAddressAsync(Guid userId, Guid addressId); + Task SaveAddressAsync(Guid userId, SaveUserAddressRequest request); + Task DeleteAddressAsync(Guid userId, Guid addressId); +} diff --git a/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs b/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs index 03ceb59..8f02c1a 100644 --- a/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs +++ b/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs @@ -5,5 +5,6 @@ namespace MemberCenter.Application.Abstractions; public interface ISecuritySettingsService { Task GetAsync(); - Task SaveAsync(SecuritySettingsDto settings); + Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null); + Task SendTestEmailAsync(string toEmail, Guid? actorUserId = null); } diff --git a/src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs b/src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs new file mode 100644 index 0000000..4d60279 --- /dev/null +++ b/src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs @@ -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"; +} diff --git a/src/MemberCenter.Application/Models/Admin/AuditLogDto.cs b/src/MemberCenter.Application/Models/Admin/AuditLogDto.cs index 1333e43..08bc116 100644 --- a/src/MemberCenter.Application/Models/Admin/AuditLogDto.cs +++ b/src/MemberCenter.Application/Models/Admin/AuditLogDto.cs @@ -5,4 +5,5 @@ public sealed record AuditLogDto( string ActorType, Guid? ActorId, string Action, + string? PayloadJson, DateTimeOffset CreatedAt); diff --git a/src/MemberCenter.Application/Models/Admin/SecuritySettingsDto.cs b/src/MemberCenter.Application/Models/Admin/SecuritySettingsDto.cs index c5810cf..e8d24ff 100644 --- a/src/MemberCenter.Application/Models/Admin/SecuritySettingsDto.cs +++ b/src/MemberCenter.Application/Models/Admin/SecuritySettingsDto.cs @@ -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); diff --git a/src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs b/src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs new file mode 100644 index 0000000..092075b --- /dev/null +++ b/src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs @@ -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); diff --git a/src/MemberCenter.Application/Models/Admin/UserGovernanceSummaryDto.cs b/src/MemberCenter.Application/Models/Admin/UserGovernanceSummaryDto.cs new file mode 100644 index 0000000..c96841b --- /dev/null +++ b/src/MemberCenter.Application/Models/Admin/UserGovernanceSummaryDto.cs @@ -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); diff --git a/src/MemberCenter.Application/Models/Email/EmailTemplate.cs b/src/MemberCenter.Application/Models/Email/EmailTemplate.cs new file mode 100644 index 0000000..d2afcea --- /dev/null +++ b/src/MemberCenter.Application/Models/Email/EmailTemplate.cs @@ -0,0 +1,6 @@ +namespace MemberCenter.Application.Models.Email; + +public sealed record EmailTemplate( + string Subject, + string TextBody, + string HtmlBody); diff --git a/src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs b/src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs new file mode 100644 index 0000000..7fb21cb --- /dev/null +++ b/src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs @@ -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); diff --git a/src/MemberCenter.Application/Models/Profile/SaveUserProfileRequest.cs b/src/MemberCenter.Application/Models/Profile/SaveUserProfileRequest.cs new file mode 100644 index 0000000..f9dfc16 --- /dev/null +++ b/src/MemberCenter.Application/Models/Profile/SaveUserProfileRequest.cs @@ -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); diff --git a/src/MemberCenter.Application/Models/Profile/UserAddressDto.cs b/src/MemberCenter.Application/Models/Profile/UserAddressDto.cs new file mode 100644 index 0000000..1d90029 --- /dev/null +++ b/src/MemberCenter.Application/Models/Profile/UserAddressDto.cs @@ -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); diff --git a/src/MemberCenter.Application/Models/Profile/UserProfileDto.cs b/src/MemberCenter.Application/Models/Profile/UserProfileDto.cs new file mode 100644 index 0000000..9da0f92 --- /dev/null +++ b/src/MemberCenter.Application/Models/Profile/UserProfileDto.cs @@ -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); diff --git a/src/MemberCenter.Application/Models/Profile/UserSubscriptionSummaryDto.cs b/src/MemberCenter.Application/Models/Profile/UserSubscriptionSummaryDto.cs new file mode 100644 index 0000000..5e5a9b4 --- /dev/null +++ b/src/MemberCenter.Application/Models/Profile/UserSubscriptionSummaryDto.cs @@ -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); diff --git a/src/MemberCenter.Domain/Entities/UserAddress.cs b/src/MemberCenter.Domain/Entities/UserAddress.cs new file mode 100644 index 0000000..b16d8bd --- /dev/null +++ b/src/MemberCenter.Domain/Entities/UserAddress.cs @@ -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; +} diff --git a/src/MemberCenter.Domain/Entities/UserProfile.cs b/src/MemberCenter.Domain/Entities/UserProfile.cs new file mode 100644 index 0000000..19ab146 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/UserProfile.cs @@ -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; +} diff --git a/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs b/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs index 6792d8a..c5e3542 100644 --- a/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs +++ b/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs @@ -1,3 +1,4 @@ +using MemberCenter.Domain.Entities; using Microsoft.AspNetCore.Identity; namespace MemberCenter.Infrastructure.Identity; @@ -8,4 +9,11 @@ public class ApplicationUser : IdentityUser 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 Addresses { get; set; } = new(); } diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index 6b01269..06fa2e4 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -16,6 +16,8 @@ public class MemberCenterDbContext public DbSet Tenants => Set(); public DbSet NewsletterLists => Set(); public DbSet NewsletterSubscriptions => Set(); + public DbSet UserProfiles => Set(); + public DbSet UserAddresses => Set(); public DbSet EmailBlacklist => Set(); public DbSet EmailVerifications => Set(); public DbSet UnsubscribeTokens => Set(); @@ -76,6 +78,67 @@ public class MemberCenterDbContext .OnDelete(DeleteBehavior.SetNull); }); + builder.Entity(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() + .WithOne(x => x.Profile) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + builder.Entity(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() + .WithMany(x => x.Addresses) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(entity => { entity.ToTable("email_blacklist"); diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs new file mode 100644 index 0000000..f1afb23 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.Designer.cs @@ -0,0 +1,1070 @@ +// +using System; +using System.Collections.Generic; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260416161322_AddUserProfilesAndAddresses")] + partial class AddUserProfilesAndAddresses + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlacklistedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("BlacklistedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("email_blacklist", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_email_verifications_email"); + + b.HasIndex("TenantId"); + + b.ToTable("email_verifications", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("newsletter_lists", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ListId") + .HasColumnType("uuid"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_newsletter_subscriptions_email"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_newsletter_subscriptions_list_id"); + + b.HasIndex("UserId"); + + b.HasIndex("ListId", "Email") + .IsUnique(); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.SystemFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("system_flags", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("unsubscribe_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLine2") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressMetaJson") + .HasColumnType("jsonb"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("District") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RecipientName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RecipientPhone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StateRegion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Usage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("shipping"); + + b.Property("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("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Department") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("unspecified"); + + b.Property("InvoiceTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LandlinePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MobilePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Remark") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaxId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BlacklistedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BlacklistedBy") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlacklisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") + .WithMany("NewsletterLists") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterList", "List") + .WithMany("Subscriptions") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("List"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + 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"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs new file mode 100644 index 0000000..d1c5191 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260416161322_AddUserProfilesAndAddresses.cs @@ -0,0 +1,159 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserProfilesAndAddresses : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisabledAt", + table: "users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "DisabledBy", + table: "users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastLoginAt", + table: "users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastSeenAt", + table: "users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "user_addresses", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + RecipientName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + RecipientPhone = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CountryCode = table.Column(type: "character varying(2)", maxLength: 2, nullable: false), + PostalCode = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + StateRegion = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + City = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + District = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + AddressLine1 = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + AddressLine2 = table.Column(type: "character varying(255)", maxLength: 255, nullable: true), + CompanyName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Usage = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "shipping"), + IsDefault = table.Column(type: "boolean", nullable: false, defaultValue: false), + AddressMetaJson = table.Column(type: "jsonb", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + LastName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + FirstName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + NickName = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + MobilePhone = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + LandlinePhone = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DateOfBirth = table.Column(type: "date", nullable: true), + Gender = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "unspecified"), + CompanyName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Department = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + JobTitle = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CompanyPhone = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TaxId = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + InvoiceTitle = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Remark = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + UpdatedAt = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index 71f445f..60f576a 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -283,6 +283,180 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("unsubscribe_tokens", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.UserAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressLine2") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AddressMetaJson") + .HasColumnType("jsonb"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CountryCode") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("District") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RecipientName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RecipientPhone") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StateRegion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Usage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("shipping"); + + b.Property("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("UserId") + .HasColumnType("uuid"); + + b.Property("CompanyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompanyPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Department") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("unspecified"); + + b.Property("InvoiceTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("JobTitle") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LandlinePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MobilePhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Remark") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TaxId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("Id") @@ -339,6 +513,12 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("now()"); + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisabledBy") + .HasColumnType("text"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("character varying(256)"); @@ -351,6 +531,12 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + b.Property("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", 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"); diff --git a/src/MemberCenter.Infrastructure/Services/AccountEmailService.cs b/src/MemberCenter.Infrastructure/Services/AccountEmailService.cs new file mode 100644 index 0000000..e7344f3 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/AccountEmailService.cs @@ -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 _userManager; + + public AccountEmailService( + ISecuritySettingsService securitySettingsService, + IAccountEmailTemplateService templateService, + IAuditLogWriter auditLogWriter, + IEmailSender emailSender, + UserManager 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 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; + } +} diff --git a/src/MemberCenter.Infrastructure/Services/AccountEmailTemplateService.cs b/src/MemberCenter.Infrastructure/Services/AccountEmailTemplateService.cs new file mode 100644 index 0000000..ccd9d20 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/AccountEmailTemplateService.cs @@ -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}", + $"

Please verify your email by clicking this link.

"); + } + + public EmailTemplate BuildPasswordResetEmail(string resetUrl) + { + return new EmailTemplate( + "Reset your password", + $"Use this link to reset your password: {resetUrl}", + $"

Use this link to reset your password.

"); + } +} diff --git a/src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs b/src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs new file mode 100644 index 0000000..b770610 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/AccountGovernanceService.cs @@ -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 _userManager; + private readonly RoleManager _roleManager; + private readonly IAuditLogWriter _auditLogWriter; + + public AccountGovernanceService( + MemberCenterDbContext dbContext, + UserManager userManager, + RoleManager roleManager, + IAuditLogWriter auditLogWriter) + { + _dbContext = dbContext; + _userManager = userManager; + _roleManager = roleManager; + _auditLogWriter = auditLogWriter; + } + + public async Task> 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(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() + .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() + .Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked) + .ToListAsync(); + + foreach (var authorization in authorizations) + { + authorization.Status = OpenIddictConstants.Statuses.Revoked; + } + + await _dbContext.SaveChangesAsync(); + } +} diff --git a/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs b/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs index 1c76832..113565f 100644 --- a/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs +++ b/src/MemberCenter.Infrastructure/Services/AccountProvisioningService.cs @@ -16,17 +16,20 @@ public sealed class AccountProvisioningService : IAccountProvisioningService private readonly UserManager _userManager; private readonly MemberCenterDbContext _dbContext; private readonly ISendEngineWebhookPublisher _webhookPublisher; + private readonly IAuditLogWriter _auditLogWriter; private readonly ILogger _logger; public AccountProvisioningService( UserManager userManager, MemberCenterDbContext dbContext, ISendEngineWebhookPublisher webhookPublisher, + IAuditLogWriter auditLogWriter, ILogger 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, diff --git a/src/MemberCenter.Infrastructure/Services/AuditLogService.cs b/src/MemberCenter.Infrastructure/Services/AuditLogService.cs index 09d36ee..bffb2f6 100644 --- a/src/MemberCenter.Infrastructure/Services/AuditLogService.cs +++ b/src/MemberCenter.Infrastructure/Services/AuditLogService.cs @@ -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(); } } diff --git a/src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs b/src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs new file mode 100644 index 0000000..74a0885 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs @@ -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(); + } +} diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs index 9a98237..0dbe32f 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs @@ -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> 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 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> 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 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); diff --git a/src/MemberCenter.Infrastructure/Services/ProfileService.cs b/src/MemberCenter.Infrastructure/Services/ProfileService.cs new file mode 100644 index 0000000..fe6c0c2 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/ProfileService.cs @@ -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 AllowedGenders = new(StringComparer.OrdinalIgnoreCase) + { + "male", + "female", + "other", + "unspecified" + }; + + private static readonly HashSet AllowedAddressUsages = new(StringComparer.OrdinalIgnoreCase) + { + "shipping", + "billing", + "both" + }; + + private readonly MemberCenterDbContext _dbContext; + private readonly IAuditLogWriter _auditLogWriter; + private readonly UserManager _userManager; + + public ProfileService(MemberCenterDbContext dbContext, IAuditLogWriter auditLogWriter, UserManager userManager) + { + _dbContext = dbContext; + _auditLogWriter = auditLogWriter; + _userManager = userManager; + } + + public async Task 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 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> 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 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 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 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); + } +} diff --git a/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs b/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs index dbccce2..7fb6a77 100644 --- a/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs +++ b/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs @@ -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 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 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("

這是一封測試信,代表 SMTP 設定可正常寄送。

", 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 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 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 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 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; } } + } diff --git a/src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs b/src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs new file mode 100644 index 0000000..b29cd26 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs @@ -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 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."); + } + } +} diff --git a/src/MemberCenter.Installer/Program.cs b/src/MemberCenter.Installer/Program.cs index 985c353..44865d7 100644 --- a/src/MemberCenter.Installer/Program.cs +++ b/src/MemberCenter.Installer/Program.cs @@ -54,7 +54,7 @@ var targetMigrationOption = new Option( 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>(); var roleManager = scope.ServiceProvider.GetRequiredService>(); - 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; } diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs new file mode 100644 index 0000000..309111a --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs @@ -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 _userManager; + + public AccountsController( + IAccountGovernanceService accountGovernanceService, + UserManager userManager) + { + _accountGovernanceService = accountGovernanceService; + _userManager = userManager; + } + + [HttpGet("")] + public async Task 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 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 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 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 GetCurrentUserIdAsync() + { + var user = await _userManager.GetUserAsync(User); + return user?.Id; + } + + private static IReadOnlyList ApplyFilters( + IReadOnlyList 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(); + } +} diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs index 51beb3d..d1d1910 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/SecurityController.cs @@ -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 _userManager; - public SecurityController(ISecuritySettingsService settingsService) + public SecurityController(ISecuritySettingsService settingsService, UserManager userManager) { _settingsService = settingsService; + _userManager = userManager; } [HttpGet("")] @@ -32,8 +37,59 @@ public class SecurityController : Controller return View("Index", model); } - await _settingsService.SaveAsync(model); - ViewData["Result"] = "Saved"; - return View("Index", 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 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 GetCurrentUserIdAsync() + { + var user = await _userManager.GetUserAsync(User); + return user?.Id; } } diff --git a/src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs b/src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs new file mode 100644 index 0000000..2a71ae8 --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Models/AccountsIndexViewModel.cs @@ -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 Items { get; set; } = Array.Empty(); +} diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml new file mode 100644 index 0000000..7d3150b --- /dev/null +++ b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml @@ -0,0 +1,116 @@ +@model MemberCenter.Web.Areas.Admin.Models.AccountsIndexViewModel + +

Accounts

+ +@if (TempData["Result"] is string result) +{ +
@result
+} + +@if (TempData["Error"] is string error) +{ +
@error
+} + +
+ + + + + +
+ +@if (!Model.CanManage) +{ +
This page is visible to admin, but only superuser can change roles or account status.
+} + + + + + + + + + + + + + + + + @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"; + + + + + + + + + + + } + +
EmailNameVerifiedRolesStatusLast LoginCreatedActions
@item.Email + @(string.IsNullOrWhiteSpace(fullName) ? "-" : fullName) + @if (!string.IsNullOrWhiteSpace(item.NickName)) + { + (@item.NickName) + } + @(item.EmailConfirmed ? "Yes" : "No")@roleSummary@statusSummary@(item.LastLoginAt?.ToString("u") ?? "-")@item.CreatedAt.ToString("u") + @if (Model.CanManage && !item.IsSuperuser) + { +
+
+ @Html.AntiForgeryToken() + + + + + + +
+
+ @Html.AntiForgeryToken() + + + + + + +
+
+ @Html.AntiForgeryToken() + + + + + + +
+
+ } + else + { + No actions + } +
diff --git a/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml index 2476e8f..d939256 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/AuditLogs/Index.cshtml @@ -3,7 +3,7 @@

Audit Logs

- + @foreach (var log in Model) @@ -11,6 +11,7 @@ + } diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml index 034c996..1701e13 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Home/Index.cshtml @@ -1,2 +1,2 @@

Admin

-

Use the admin group in the main navigation to manage tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.

+

Use the admin group in the main navigation to manage accounts, tenants, lists, subscriptions, OAuth clients, audit logs, security, and blacklist records.

diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml index 0a8bc7b..94536fa 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml @@ -5,12 +5,70 @@ {

@ViewData["Result"]

} - + + @Html.AntiForgeryToken() +
- + - + + + + + +

SMTP

+ + + + + + + + + + + + + + + + + + + + + @if (Model.HasSmtpPassword) + { +

Password saved. Leave blank to keep current password.

+ } + + + + + + + +

Test Email

+ + @Html.AntiForgeryToken() + + + + + + + + + + + + + + + + + diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml index 734ea1c..6ee5c69 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Shared/_Layout.cshtml @@ -37,6 +37,7 @@
ActionActorTime
ActionActorPayloadTime
@log.Action @log.ActorType @log.ActorId@log.PayloadJson @log.CreatedAt
+ + + + + + + + + + + @foreach (var address in Model.Addresses) + { + + + + + + + + } + +
LabelRecipientUsageDefault
@address.Label@address.RecipientName@address.Usage@(address.IsDefault ? "Yes" : "No") + Edit +
+ @Html.AntiForgeryToken() + +
+
+} + +

@(Model.Form.Id.HasValue ? "Edit Address" : "Add Address")

+
+ @Html.AntiForgeryToken() + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/MemberCenter.Web/Views/Profile/Index.cshtml b/src/MemberCenter.Web/Views/Profile/Index.cshtml index 61c916e..47aba16 100644 --- a/src/MemberCenter.Web/Views/Profile/Index.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Index.cshtml @@ -1,7 +1,59 @@ -@model MemberCenter.Infrastructure.Identity.ApplicationUser +@model MemberCenter.Web.Models.Profile.ProfileViewModel

Profile

-

Email: @Model.Email

-

Verified: @Model.EmailConfirmed

-

Created: @Model.CreatedAt

+@if (ViewData["Result"] is not null) +{ +

@ViewData["Result"]

+} +@if (TempData["Result"] is string result) +{ +

@result

+} + +
+ @Html.AntiForgeryToken() +
+

Email: @Model.Email

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Change Password

+
+ @Html.AntiForgeryToken() + +
+

Manage Addresses

+

Manage Subscriptions

diff --git a/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml b/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml new file mode 100644 index 0000000..93869b5 --- /dev/null +++ b/src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml @@ -0,0 +1,43 @@ +@model MemberCenter.Web.Models.Profile.SubscriptionsPageViewModel + +

My Subscriptions

+@if (!Model.Subscriptions.Any()) +{ +

No subscriptions linked to this account.

+} +else +{ + + + + + + + + + + + + + @foreach (var subscription in Model.Subscriptions) + { + + + + + + + + + } + +
TenantListStatusEmailCreated
@subscription.TenantName@subscription.ListName@subscription.Status@subscription.Email@subscription.CreatedAt + @if (!string.Equals(subscription.Status, "unsubscribed", StringComparison.OrdinalIgnoreCase)) + { +
+ @Html.AntiForgeryToken() + +
+ } +
+} diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index 7e612da..d7f791a 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -39,12 +39,13 @@ Preferences - @if (User.IsInRole("admin")) + @if (User.IsInRole("admin") || User.IsInRole("superuser")) {