Compare commits

..

2 Commits

Author SHA1 Message Date
Warren Chen
c9c0396ad2 Implement member profile, email flows, and account governance 2026-04-17 03:46:32 +09:00
Warren Chen
5752d649e0 feat: Enhance member center functionalities and API scopes
- Added subscription management for logged-in users, allowing them to view and unsubscribe from newsletters directly.
- Introduced profile maintenance features, enabling users to update personal information and manage addresses.
- Expanded API scopes for profile access, including read/write permissions for basic info, addresses, and subscriptions.
- Updated installation documentation to reflect new audience settings for authentication resources.
- Improved OpenAPI documentation with new profile scopes and authorization models.
- Created a comprehensive member upgrade plan outlining system settings, email verification, account roles, and subscription management.
2026-04-16 06:14:01 +09:00
73 changed files with 5494 additions and 92 deletions

View File

@ -44,6 +44,7 @@
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信) - `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
- `docs/TECH_STACK.md`:技術棧與選型 - `docs/TECH_STACK.md`:技術棧與選型
- `docs/INSTALL.md`:安裝、初始化與維運指令 - `docs/INSTALL.md`:安裝、初始化與維運指令
- `docs/MEMBER_UPGRADE_PLAN.md`會員中心下一階段升級規劃設定畫面、SMTP、Email 驗證、忘記密碼、角色分級)
## 專案結構 ## 專案結構
```text ```text

View File

@ -13,11 +13,12 @@
- OAuth2 + OIDCAuthorization Code + PKCE - OAuth2 + OIDCAuthorization Code + PKCE
- 會員中心只管理 Email 與訂閱狀態 - 會員中心只管理 Email 與訂閱狀態
- 檔案存取授權沿用 OAuth2/JWT/JWKS並以 scope + claim 做資源邊界控制 - 檔案存取授權沿用 OAuth2/JWT/JWKS並以 scope + claim 做資源邊界控制
- 對外資料 API 以 service API 為主,授權完全由 scope 控制
- Double Opt-in - Double Opt-in
- 各站自行設計 UI主要走 API少數狀況使用 redirect - 各站自行設計 UI主要走 API少數狀況使用 redirect
- 多租戶為邏輯隔離,但會員資料跨站共享 - 多租戶為邏輯隔離,但會員資料跨站共享
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作 - 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
- 訂閱狀態同步目前採 webhookevent payloadqueue 為後續可選擴充 - 訂閱狀態同步採 webhookevent payloadqueue 可作為未來擴充選項
- PostgreSQL - PostgreSQL
- 實作C# .NET Core + MVC + OpenIddict - 實作C# .NET Core + MVC + OpenIddict
@ -31,6 +32,8 @@
## 4. 核心模組 ## 4. 核心模組
- Identity Service註冊、登入、修改密碼、密碼重設、Email 驗證 - Identity Service註冊、登入、修改密碼、密碼重設、Email 驗證
- OAuth2/OIDC Service授權流程、token 發放、ID Token - OAuth2/OIDC Service授權流程、token 發放、ID Token
- Auth Resource Registry管理外部服務 resource / audience / scopes / client usage 對應
- Profile Service會員基本資料、聯絡方式、公司資料、地址簿
- Subscription Service訂閱/退訂/偏好管理 - Subscription Service訂閱/退訂/偏好管理
- Admin Console租戶與清單管理 - Admin Console租戶與清單管理
- Mailer Integration驗證信/退訂信/確認信的發送介面(外部系統) - Mailer Integration驗證信/退訂信/確認信的發送介面(外部系統)
@ -40,7 +43,22 @@
- tenants - tenants
- id, name, domains, status, created_at - id, name, domains, status, created_at
- users (ASP.NET Core Identity) - users (ASP.NET Core Identity)
- id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at - id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at, last_login_at, last_seen_at, disabled_at, disabled_by
- user_profiles
- user_id, last_name, first_name, nick_name, mobile_phone, landline_phone, date_of_birth, gender, company_name, department, job_title, company_phone, tax_id, invoice_title, remark, updated_at
- user_addresses
- id, user_id, label, recipient_name, recipient_phone, country_code, postal_code, state_region, city, district, address_line1, address_line2, company_name, usage, is_default, address_meta_json, created_at, updated_at
資料約束建議:
- `user_profiles.first_name``user_profiles.last_name` 必填
- `user_profiles.gender` 使用列舉:`male` / `female` / `other` / `unspecified`
- `user_profiles.date_of_birth` 使用 `date`
- `user_addresses.country_code` 使用 ISO 3166-1 alpha-2
- `user_addresses.address_line1` 必填
- `user_addresses.usage` 使用列舉:`shipping` / `billing` / `both`
- 同一個 `user_id + usage` 只允許一筆預設地址
- 地址至少保留一筆,禁止刪除最後一筆地址
- `address_meta_json` 作為國家特有欄位補充,不取代主結構欄位
- roles / user_roles (Identity) - roles / user_roles (Identity)
- id, name, created_at - id, name, created_at
- OpenIddictApplications - OpenIddictApplications
@ -71,6 +89,7 @@
- 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id - 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id
- 單一清單退訂unsubscribe token 綁定 subscription_id - 單一清單退訂unsubscribe token 綁定 subscription_id
- blacklist 記錄於 email_blacklist全租戶共用 - blacklist 記錄於 email_blacklist全租戶共用
- email 為會員與訂閱領域的對外主 key不提供改 email 流程
## 6. 核心流程 ## 6. 核心流程
@ -132,6 +151,32 @@
4) 系統驗證目前密碼正確後更新 password hash 4) 系統驗證目前密碼正確後更新 password hash
5) 更新成功後刷新目前 session 5) 更新成功後刷新目前 session
### 6.8b 會員基本資料維護
1) 使用者登入會員中心
2) 進入 profile 頁
3) 讀取基本資料姓、名、nick name、電話、生日、性別、公司、部門、職稱、統編、remark 等)
4) 更新後寫入 `user_profiles`
5) 記錄 audit log
### 6.8c 地址簿管理
1) 使用者登入會員中心
2) 進入地址簿頁面
3) 新增、編輯或刪除地址
4) 系統驗證地址資料歸屬目前 user
5) 若設定預設地址,需確保同用途只有一筆預設值
6) 若只剩最後一筆地址,不允許刪除
7) 記錄 audit log
### 6.8d 已登入會員管理自己的訂閱
1) 使用者登入會員中心
2) 系統依 `user_id` 讀取已綁定的電子報訂閱
3) 顯示可管理的訂閱清單
4) 使用者可直接取消訂閱
5) 系統驗證訂閱歸屬目前 user 後更新狀態
6) 發送 `subscription.unsubscribed` 事件
7) 不需再次經過 email token 驗證
8) 已綁定 `user_id` 後仍保留 `list_id + email` 的 public 訂閱 / 退訂入口
### 6.9 檔案存取授權File Access ### 6.9 檔案存取授權File Access
1) Upload 採 S2S 1) Upload 採 S2S
- `A service``client_credentials` 向 Member Center 取 token - `A service``client_credentials` 向 Member Center 取 token
@ -168,6 +213,41 @@
- `tenant_id` - `tenant_id`
- 檔案識別與 method 是否與 token 一致 - 檔案識別與 method 是否與 token 一致
### 6.10 外部資源授權抽象Audience / Resource Registry
目的:
- 統一 Send Engine、File Access 與未來其他外部服務的 token 發放規則
- 避免每新增一個服務就新增一組 `Auth__XAudience` 與對應程式分支
共通原則:
- Member Center 為 token issuer 與 JWKS 提供者
- 外部服務皆以 `iss/aud/exp/scope/tenant_id` 驗證 token
- `aud` 不直接以程式硬編碼,而是由 resource registry 決定
resource registry 至少需定義:
- `resource_name`
- `audience`
- `allowed_scopes`
- `allowed_client_usages`
- `requires_tenant`
- `allows_delegated_token`
建議初始資源:
- `member_center_api`
- scopes`openid``email``profile``newsletter:list.read``newsletter:events.write``newsletter:events.write.global``profile:basic.read``profile:basic.write``profile:addresses.read``profile:addresses.write``profile:subscriptions.read``profile:subscriptions.write`
- usages`tenant_api``platform_service`、互動式登入 client
- `send_engine_api`
- scopes`newsletter:send.write``newsletter:send.read`
- usages`send_api`
- `file_access_api`
- scopes`files:upload.write``files:download.read``files:download.delegate``files:delete``files:metadata.read`
- usages`file_api`
設計原則:
- resource registry 由 DB 與管理 UI 管理非敏感欄位
- `TokenController` 依 scope 與 usage 對照 resource registry 計算 resources / audiences
- delegated token 與一般 S2S token 共用同一套 resource registry不重複維護 audience 規則
- 對外資料讀寫授權完全由 scope 決定,只要 client 被授權該 scope 即可存取對應能力
## 7. API 介面(草案) ## 7. API 介面(草案)
- GET `/oauth/authorize` - GET `/oauth/authorize`
- POST `/oauth/token` - POST `/oauth/token`
@ -197,10 +277,17 @@
### Auth / Scope ### Auth / Scope
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 - `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service`(禁止混用) - OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service` / `file_api`(禁止混用)
- 新增 scope`newsletter:list.read``newsletter:send.write``newsletter:send.read``newsletter:events.read` - 新增 scope`newsletter:list.read``newsletter:send.write``newsletter:send.read``newsletter:events.read`
- 新增 scope`newsletter:events.write` - 新增 scope`newsletter:events.write`
- 新增 scope`newsletter:events.write.global` - 新增 scope`newsletter:events.write.global`
- 規劃新增 profile scopes
- `profile:basic.read`
- `profile:basic.write`
- `profile:addresses.read`
- `profile:addresses.write`
- `profile:subscriptions.read`
- `profile:subscriptions.write`
- 規劃新增 file access scopes - 規劃新增 file access scopes
- `files:upload.write` - `files:upload.write`
- `files:download.read` - `files:download.read`
@ -209,12 +296,16 @@
- `files:metadata.read` - `files:metadata.read`
- 規劃新增 audience`file_access_api` - 規劃新增 audience`file_access_api`
- JWT Access Token 已改為 JWS`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽 - JWT Access Token 已改為 JWS`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽
- `aud` 計算由 resource registry 驅動,不於 token 發放流程硬寫各服務 audience
### 租戶端取 TokenClient Credentials ### 租戶端取 TokenClient Credentials
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token - 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
- token 內含 `tenant_id` 與 scope - token 內含 `tenant_id` 與 scope
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS - Send Engine 收到租戶請求後以 JWKS 驗簽 JWTJWS
- 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫 - 驗簽通過後將 `tenant_id` 固定在 request context不接受 body 覆寫
- File Access 與未來外部服務亦沿用此模型,差異由 scopes / audience / delegated token 規則表達
- 若其他服務要讀取會員個資,應只授予必要的 profile read scopes不應沿用過寬的 `profile` OIDC scope
- service API 為主要整合模式,存取控制以 scopes 為唯一授權來源
## 7.2 尚未完成(待辦) ## 7.2 尚未完成(待辦)
- `POST /webhooks/lists/full-sync`Member Center 端尚未發送此事件(僅保留契約) - `POST /webhooks/lists/full-sync`Member Center 端尚未發送此事件(僅保留契約)
@ -222,6 +313,7 @@
- `subscription.linked_to_user` 事件已發送 - `subscription.linked_to_user` 事件已發送
- 安全設定頁access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime - 安全設定頁access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足 - Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
- resource registry 與 file access token issuing 尚未實作,現況仍是兩組 audience 硬編碼
## 8. 安全與合規 ## 8. 安全與合規
- 密碼強度與防暴力破解rate limit + lockout - 密碼強度與防暴力破解rate limit + lockout
@ -229,6 +321,8 @@
- Redirect URI 白名單 + PKCE - Redirect URI 白名單 + PKCE
- Double opt-in可配置 - Double opt-in可配置
- Audit log - Audit log
- delegated download token 需短效、不可重放,必要時可引入 `jti` 與 nonce/jti blacklist
- Email 可作為未來 MFA 的挑戰通道
- GDPR/CCPA資料匯出與刪除規劃中 - GDPR/CCPA資料匯出與刪除規劃中
## 9. 其他文件 ## 9. 其他文件

View File

@ -74,6 +74,15 @@
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences` - [API] 站點以 `list_id + email` 更新 `/newsletter/preferences`
- [UI] 會員中心提供偏好頁(可選) - [UI] 會員中心提供偏好頁(可選)
## F-06b 我的電子報訂閱管理(登入後)
- [UI] 使用者登入後進入 `/profile/subscriptions`
- [API/UI] 會員中心依目前登入 user 查出其已綁定的訂閱清單
- [UI] 顯示各租戶 / 清單 / 狀態 / 訂閱時間
- [UI] 使用者可直接按下取消訂閱
- [API/UI] 系統驗證該訂閱屬於目前登入 user 後直接退訂
- [API/UI] 不需再次 email token 驗證
- [API] 發出 event `subscription.unsubscribed`
## F-10 Send Engine 事件同步Member Center → Send Engine ## F-10 Send Engine 事件同步Member Center → Send Engine
- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`scope: `newsletter:events.write` - [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`scope: `newsletter:events.write`
- [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id` - [API] Header 使用 `X-Signature` / `X-Timestamp` / `X-Nonce` / `X-Client-Id`
@ -101,6 +110,24 @@
- [API] 站點讀取 `/user/profile` - [API] 站點讀取 `/user/profile`
- [UI] 會員中心提供個人資料頁 - [UI] 會員中心提供個人資料頁
## F-07b 會員資料維護
- [UI] 使用者登入後進入 `/profile`
- [UI] 維護電話、公司、統編等基本資料
- [API/UI] 儲存會員基本資料
## F-07c 地址簿管理
- [UI] 使用者登入後進入 `/profile/addresses`
- [UI] 新增、編輯、刪除收貨地址
- [UI] 可設定預設地址
- [API/UI] 地址簿資料綁定目前登入 user
- [API/UI] 若只剩最後一筆地址,不允許刪除
## F-07d 其他服務讀取會員資料
- [API] 其他服務以 access token 呼叫會員中心 profile API
- [API] token 需帶對應 profile scopes
- [API] 會員中心依 scope 決定可讀取基本資料、地址簿或訂閱資料
- [API] service API 為主要模式,只要 client 具對應 scope 即可存取對應資料
## F-08 管理者管理租戶/清單/Client ## F-08 管理者管理租戶/清單/Client
- [UI] 會員中心管理後台進行 CRUD - [UI] 會員中心管理後台進行 CRUD

View File

@ -41,12 +41,20 @@
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
Auth__Issuer=http://localhost:7850/ Auth__Issuer=http://localhost:7850/
Auth__MemberCenterAudience=member_center_api Auth__Resources__MemberCenter__Audience=member_center_api
Auth__SendEngineAudience=send_engine_api Auth__Resources__SendEngine__Audience=send_engine_api
Auth__Resources__FileAccess__Audience=file_access_api
SendEngine__BaseUrl=http://localhost:6060 SendEngine__BaseUrl=http://localhost:6060
SendEngine__WebhookSecret=change-me SendEngine__WebhookSecret=change-me
``` ```
相容性說明:
- 現行程式仍使用:
- `Auth__MemberCenterAudience`
- `Auth__SendEngineAudience`
- 規劃上將收斂為 `Auth__Resources__*__Audience` 形式,避免每新增一個外部服務就再增加一組平行 env key。
- `File Access` 落地時,應直接採用 resource registry 形式,不再新增第三組硬編碼 audience 判斷。
`SendEngine` 設定說明: `SendEngine` 設定說明:
- `SendEngine__BaseUrl`: Send Engine API base URL - `SendEngine__BaseUrl`: Send Engine API base URL
- `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致 - `SendEngine__WebhookSecret`: 與 Send Engine `Webhook:Secrets:member_center` 一致
@ -74,12 +82,12 @@ SendEngine__WebhookSecret=change-me
- 若 appsettings 中缺少連線字串,會互動式詢問並寫入 - 若 appsettings 中缺少連線字串,會互動式詢問並寫入
- 若設定環境變數,會優先使用環境變數(不寫入 appsettings - 若設定環境變數,會優先使用環境變數(不寫入 appsettings
2) 執行 migrations不 Drop 2) 執行 migrations不 Drop
3) 建立 rolesadmin, support 3) 建立 rolessuperuser, admin, support
4) 建立 admin不存在才建立並加入 admin 角色 4) 建立使用者(不存在才建立)並加入 `superuser` 角色
5) 寫入安裝鎖定DB flag: `system_flags` / `installed=true` 5) 寫入安裝鎖定DB flag: `system_flags` / `installed=true`
### 2) `installer add-admin` ### 2) `installer add-superuser`
用途:新增 superuser 用途:新增或提升 superuser
參數: 參數:
- `--admin-email <email>` - `--admin-email <email>`
@ -88,10 +96,13 @@ SendEngine__WebhookSecret=change-me
流程: 流程:
1) 解析連線字串 1) 解析連線字串
2) 建立使用者並指派 admin 角色 2) 建立使用者並指派 `superuser` 角色
### 3) `installer reset-admin-password` 相容性:
用途:重設指定 admin 密碼 - 舊指令 `installer add-admin` 仍保留為 alias目前語意等同 `installer add-superuser`
### 3) `installer reset-superuser-password`
用途:重設指定 superuser 密碼
參數: 參數:
- `--admin-email <email>` - `--admin-email <email>`
@ -101,6 +112,9 @@ SendEngine__WebhookSecret=change-me
1) 解析連線字串 1) 解析連線字串
2) 更新密碼(強制) 2) 更新密碼(強制)
相容性:
- 舊指令 `installer reset-admin-password` 仍保留為 alias目前語意等同 `installer reset-superuser-password`
### 4) `installer migrate` ### 4) `installer migrate`
用途:只執行 migrations 用途:只執行 migrations

784
docs/MEMBER_UPGRADE_PLAN.md Normal file
View File

@ -0,0 +1,784 @@
# 會員中心升級規劃
此文件整理會員中心的目標升級架構包含設定畫面、Email 驗證與忘記密碼、帳號分級與角色管理、會員主資料、訂閱管理,以及作為外部服務 Token / Auth 中心的整體模型。此文件以一次到位的最終設計為準,不再以過渡方案或分階段落地作為主軸。
## 目標
- 增加管理者可操作的系統設定畫面,涵蓋 SMTP、Send Engine 與 Auth 資源設定。
- 補齊 Email 驗證與忘記密碼的完整寄信流程。
- 建立帳號分級規則,明確區分 `superuser``admin` 與一般會員。
- 確立會員認證狀態模型,以「已認證 / 未認證」為會員狀態基礎。
- 第三方登入以 Google 為唯一支援 provider。
- 將「會員中心作為 Token / Auth 中心」的外部服務授權模型文件化,納入 Send Engine 與 File Access。
- 擴充會員個人資料、地址簿與會員端訂閱管理能力,並同步定義可供其他服務使用的 profile scopes。
- 補齊帳號生命週期、審計紀錄、rate limit 與 MFA 的基礎治理規則。
## 實作進度2026-04-17
已完成:
- 建立 `user_profiles` / `user_addresses` 實體、DbContext 映射與 EF migration
- `users` 新增治理欄位:
- `last_login_at`
- `last_seen_at`
- `disabled_at`
- `disabled_by`
- 註冊與 external login 建立新帳號時,會同步建立空白 profile row
- 新增 current-user profile API
- `GET /user/profile`
- `POST /user/profile`
- `GET /user/addresses`
- `POST /user/addresses`
- `DELETE /user/addresses/{id}`
- `GET /user/subscriptions`
- `POST /user/subscriptions/{id}/unsubscribe`
- 新增會員端 Web UI
- `/profile`
- `/profile/addresses`
- `/profile/subscriptions`
- 新增會員端直接退訂流程,不需再透過 email token
- `profile:*` scopes 已註冊進 OpenIddict
- `profile:basic.read`
- `profile:basic.write`
- `profile:addresses.read`
- `profile:addresses.write`
- `profile:subscriptions.read`
- `profile:subscriptions.write`
- API 已接上 profile scope policies並補 service API 的 by-email 端點:
- `GET /user/profile/by-email`
- `POST /user/profile/by-email`
- `GET /user/addresses/by-email`
- `POST /user/addresses/by-email`
- `DELETE /user/addresses/by-email/{id}`
- `GET /user/subscriptions/by-email`
- `POST /user/subscriptions/by-email/{id}/unsubscribe`
- token resource 映射已將 `profile:*` 納入 member center audience
- `/admin/security` 已擴充 SMTP 設定欄位:
- relay host / port
- TLS / SSL
- timeout
- username / password
- sender name / sender email
- `/admin/security` 已新增 SMTP 測試信功能,可輸入測試收件 Email 送出測試信
- SMTP 設定已收斂到 DB flags密碼欄位留白時保留既有值
- 已建立共用帳號寄信服務,用於 Email 驗證信與密碼重設信
- 已抽出共用帳號 Email 模板服務:
- 驗證信模板
- 密碼重設信模板
- 註冊流程已改為寄送 Email 驗證信
- forgot password 已改為寄送重設信,不再直接回傳 reset token
- 已補 resend verification
- Web`POST /account/resendverification`
- API`POST /auth/email/resend`
- `/admin/security` 已補 `PublicBaseUrl`,作為驗證信與重設信連結的基準 URL
- 已補 audit log
- `account.verification_email_sent`
- `account.password_reset_email_sent`
- `account.email_verified`
- `account.password_reset_completed`
- 已啟用 Identity lockout 基礎策略:
- `MaxFailedAccessAttempts = 5`
- `DefaultLockoutTimeSpan = 15 分鐘`
- `AllowedForNewUsers = true`
- 已落地公開入口 rate limit
- Weblogin / register / forgot password / resend verification
- APIregister / forgot password / resend verification
- Newsletter APIpublic subscribe / unsubscribe token
- password grant login 已改為走 `SignInManager.CheckPasswordSignInAsync(..., lockoutOnFailure: true)`,與 Web login 共用 lockout 行為
- 已完成 `superuser` / `admin` 權限模型第一版落地:
- `Admin` policy 已擴為接受 `admin``superuser`
- 新增 `Superuser` policy
- installer `init` / `add-superuser` 會建立或提升 `superuser`
- 舊指令別名 `add-admin` / `reset-admin-password` 仍可用
- 已新增管理後台帳號治理頁:
- `/admin/accounts`
- 支援查詢帳號、查看 email verified / role / disabled / last login
- 只有 `superuser` 可授予或移除 `admin`
- 只有 `superuser` 可停用或啟用帳號
- 已補帳號治理規則:
- `superuser` 帳號不可在管理 UI 中被降權、停用、刪除或強制變更密碼
- disabled 中的 `admin` 可保留 `admin` role
- `superuser` 可在管理 UI 強制重設非 `superuser` 帳號密碼
- 強制重設密碼後會更新 security stamp 並撤銷既有 OpenIddict authorization / token
- 已補帳號治理與生命週期 audit log
- `account.registered`
- `account.external_login_linked`
- `account.password_changed`
- `account.role_changed`
- `account.disabled`
- `account.enabled`
- `system.security_settings_updated`
- `system.security_test_email_sent`
- 已補登入治理:
- Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at`
- disabled 帳號無法透過 Web login、external login、password grant 取得登入
- Web cookie 與 API authenticated request 會檢查 disabled 狀態
進行中:
- profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示
待續作:
- OAuth client usage 與 profile scopes 的最終授權矩陣仍偏靜態,尚未進到 resource registry / DB 驅動
- Auth resource registry 與 audience/scope 資料驅動化
- Email 驗證信 / 重設信的正式模板與文案優化
- rate limit 與 lockout 規則補齊:
- one-click unsubscribe token 申請
- 服務型 token flow 與人類登入 flow 的完整差異化治理
- 更細的風控觀測與後台設定化
- audit log 與 rate limit 尚未全面覆蓋所有規劃入口與治理事件
- `superuser` / `admin` 第一版已完成,後續待細化:
- 更細權限切分
- 是否需要更多治理角色
- 管理後台帳號治理功能補強:
- 更完整排序 / 篩選進一步細化
- 更細的操作確認與保護規則擴充
## 現況盤點
### 已存在
- `MemberCenter.Web``MemberCenter.Api` 已有本地註冊、登入、忘記密碼、重設密碼、Email 驗證入口。
- `MemberCenter.Web/Controllers/AccountController.cs` 已有:
- `ForgotPassword`
- `ResetPassword`
- `VerifyEmail`
- `ExternalLogin` / `ExternalLoginCallback`
- `MemberCenter.Api/Controllers/AuthController.cs` 已有:
- `POST /auth/register`
- `POST /auth/password/forgot`
- `POST /auth/password/reset`
- `GET /auth/email/verify`
- Google external login 已在 `src/MemberCenter.Web/Program.cs` 接入,並已支援同 email auto-link。
- Installer 已可建立初始管理帳號,但角色模型仍需調整為文件定義的 `superuser`
- 管理後台已有 `/admin/security` 畫面,目前僅提供 token 時效設定:
- `AccessTokenMinutes`
- `RefreshTokenDays`
- `MemberCenter.Web` 已有 `/profile` 頁面,但目前僅顯示:
- Email
- Email 驗證狀態
- 建立時間
- `MemberCenter.Api` 已有 `GET /user/profile`,但目前只提供基礎欄位,不足以支撐其他服務查詢完整會員資料。
- `docs/DESIGN.md` / `docs/FLOWS.md` 已定義 File Access 流程:
- upload: `client_credentials` 取得 upload token 後由外部服務直連 file space
- download: 由業務服務驗權後再簽發短效 delegated download token
- `TokenController.ResolveResources()` 已依 scope 決定 token audience目前內建
- `member_center_api`
- `send_engine_api`
### 部分實作
- `SendEngine__BaseUrl``SendEngine__WebhookSecret` 仍停留在設定來源與 options binding尚未進入可編輯的管理畫面。
- Auth 資源 / audience / scope registry 仍為靜態實作,尚未資料驅動化。
- 帳號治理後台、角色模型與 disabled account 規則已完成第一版,但仍缺進一步細化與保護規則。
- profile / addresses / subscriptions 畫面與驗證目前為最小可用版本,尚未完成 UI refinement。
### 待補項
- File Access 的 OAuth client usage、scope、audience 與 delegated token 發放流程尚未落地到程式。
- Token resource / audience 的設定方式目前仍偏硬編碼,尚未抽象成可擴充模型。
- Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。
- rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測。
## 功能規劃
### 1. 系統設定畫面
狀態:`部分完成`
#### 1.1 主要新增項
- 新增獨立的「系統設定」或「整合設定」畫面。
- 第一批可編輯設定:
- SMTP Host
- SMTP Port
- SMTP Username
- SMTP Password
- SMTP From Name
- SMTP From Address
- SMTP Enable SSL / TLS
- `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 分鐘數
- Refresh token 天數
- `SendEngineWebhookOptions.SubscriptionEventsPath` 目前已有預設值 `/webhooks/subscriptions`,若未來有多環境或反向代理差異,可評估是否也納入設定畫面。
#### 1.3 暫不建議放入設定畫面的項目
- `ConnectionStrings__Default`
- `Auth__Issuer`
- `PathBase`
- Google OAuth Client Secret
以上屬部署層級或高風險設定,建議仍以環境變數或部署設定管理,不在一般管理 UI 直接編輯。
#### 1.4 設定保存策略
- 所有可運行期調整的設定統一收斂到 DB例如沿用 `system_flags` 或新增專用設定表。
- 敏感值至少需要:
- UI 遮罩顯示
- 審計紀錄
- 更新權限限制
- Secret 類設定需明確區分:
- 可明文顯示的設定
- 僅能覆寫、不可回顯的 secret 類設定
### 2. Email 驗證與忘記密碼
狀態:`核心完成,文案待續作`
#### 2.1 目標狀態
- 註冊後自動寄送 Email 驗證信。
- 使用者可重新發送驗證信。
- 忘記密碼送出後寄送 reset password email。
- Web UI 與 API 共享同一套 token 與寄信服務邏輯。
#### 2.2 目前已有的基礎
- Identity token provider 已可產生:
- email confirmation token
- password reset token
- Web 與 API 已有 verify/reset endpoint。
- View 已存在:
- `ForgotPassword`
- `ResetPassword`
- `VerifyEmailResult`
#### 2.3 待補實作
- Email 樣板正式文案整理:
- 驗證信
- 忘記密碼信
- 更完整的產品提示與畫面細節整理
#### 2.4 安全與產品規則
- 忘記密碼 API 與 UI 都應避免暴露帳號是否存在。
- Reset token 與 verify token 的 URL 應統一由設定的 public base URL 組出。
- 驗證信與重設密碼信都應記錄 audit log。
- 會員未驗證時是否允許登入與可操作範圍,需在實作時明確固定為單一規則,不保留模糊狀態。
### 3. 帳號分級與角色管理
狀態:`第一版完成,細化待續作`
#### 3.1 角色模型
- `superuser`
- 只能透過 installer 建立或提升。
- 可管理所有帳號角色。
- 可授予或移除 `admin`
- `admin`
- 必須先以一般會員身分註冊。
- 再由 `superuser` 手動授權。
- 不可自行提升其他帳號為 `admin`,除非後續明確擴權。
- `member`
- 預設註冊身分。
- 依 Email 驗證狀態再分為:
- `unverified`
- `verified`
#### 3.2 會員分級原則
- 會員狀態以「已認證 / 未認證」為基礎。
- 若未來擴充,可在不破壞既有角色模型下新增:
- VIP / 付費會員
- 停權 / 凍結
- 企業帳號等級
#### 3.3 對現行實作的調整方向
- 已完成:
- Installer 建立的高權限帳號改為 `superuser`
- 管理後台中的角色治理操作為 `superuser` 專屬
- 已新增 `/admin/accounts`,可查詢帳號、查看驗證狀態、指派或移除 `admin`、顯示 `superuser`
- 已補搜尋與篩選
- 待續作:
- 更細的角色切分與保護規則
#### 3.4 權限規則
- 只有 `superuser` 可變更角色。
- `admin` 可進入既有管理後台,但不可升降他人權限。
- 一般會員不可看見 `/admin/*`
- 未來若導入更多後台功能,建議區分:
- 後台使用權限
- 帳號治理權限
### 3.5 帳號生命週期
狀態:`部分完成`
- 帳號狀態至少包含:
- `active`
- `disabled`
- `locked`
- Email 驗證狀態獨立於帳號狀態,維持:
- `unverified`
- `verified`
- Email 為對外主識別 key不允許修改。
- 若使用者要更換 email採重新註冊新帳號不提供原帳號改 email 流程。
- 後台需保留帳號停用能力;停用後不得再登入與取得新 token。
- 建議記錄帳號治理欄位:
- `last_login_at`
- `last_seen_at`
- `disabled_at`
- `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 會員基本資料
- 會員可自行維護的基本資料欄位包含:
- `last_name`
- `first_name`
- `nick_name`
- `mobile_phone`
- `landline_phone`
- `date_of_birth`
- `gender`
- 公司名稱
- 部門
- 職稱
- 公司電話
- 統一編號
- 發票抬頭
- `remark`
- Email 為固定欄位,不提供變更流程;如需更換 email採重新註冊。
- Email 同時作為對外 API 的主 key。
建議必填欄位:
- `last_name`
- `first_name`
建議選填欄位:
- `nick_name`
- `mobile_phone`
- `landline_phone`
- `date_of_birth`
- `gender`
- `company_name`
- `department`
- `job_title`
- `company_phone`
- `tax_id`
- `invoice_title`
- `remark`
建議 enum
- `gender`
- `male`
- `female`
- `other`
- `unspecified`
欄位驗證建議:
- `first_name``last_name`
- 必填
- 長度 `1-100`
- `nick_name`
- 長度 `0-100`
- `mobile_phone``landline_phone``company_phone`
- 儲存標準化字串
- 盡量接近 E.164,市話可接受本地格式輸入後正規化
- `company_name``department``job_title`
- 長度 `0-200`
- `tax_id`
- 長度 `0-32`
- 驗證以台灣統編規則為主,但 schema 保留國際延展空間
- `invoice_title`
- 長度 `0-200`
- `remark`
- 長度 `0-1000`
- `date_of_birth`
- 使用 `date` 型別,不使用 datetime
資料使用原則:
- `first_name + last_name` 為正式姓名來源
- `nick_name` 為顯示用途,不作為唯一鍵
- `mobile_phone` 優先於 `landline_phone` 作為主要聯絡電話
目前進度:
- 上述欄位與資料模型、API、Web profile 編輯已完成第一版
- 欄位驗證與畫面體驗仍待整理
#### 5.2 地址簿 / 收貨地址清單
- 新增會員地址簿概念,支援一位會員維護多筆地址。
- 地址欄位包含:
- `label`
- 收件人姓名
- 收件人電話
- `country_code`
- `postal_code`
- `state_region`
- `city`
- `district`
- `address_line1`
- `address_line2`
- 公司名稱(可選)
- 是否預設地址
- 地址用途
- `address_meta_json`
- 地址用途建議先預留列舉:
- `shipping`
- `billing`
- `both`
- 資料格式規則:
- 以台灣使用情境為主
- 國碼、電話、郵遞區號、國家碼等欄位盡量符合國際格式
- 統編以台灣統編規則為主
- 地址採「結構化欄位 + `address_meta_json` 補充資料」混合設計,不採純 JSON
- 刪除規則:
- 若會員只剩最後一筆地址,不允許刪除
- 若刪除的是預設地址,系統需自動補選新的預設地址
建議必填欄位:
- `user_id`
- `label`
- `recipient_name`
- `recipient_phone`
- `country_code`
- `address_line1`
- `usage`
- `is_default`
建議選填欄位:
- `postal_code`
- `state_region`
- `city`
- `district`
- `address_line2`
- `company_name`
- `address_meta_json`
建議 enum
- `usage`
- `shipping`
- `billing`
- `both`
欄位驗證建議:
- `label`
- 長度 `1-100`
- `recipient_name`
- 必填
- 長度 `1-100`
- `recipient_phone`
- 必填
- 儲存標準化字串
- `country_code`
- 必填
- 使用 ISO 3166-1 alpha-2
- 固定 2 碼大寫
- `postal_code`
- 長度 `0-20`
- `state_region``city``district`
- 長度各 `0-100`
- `address_line1`
- 必填
- 長度 `1-255`
- `address_line2`
- 長度 `0-255`
- `company_name`
- 長度 `0-200`
- `address_meta_json`
- 可為 `null`
資料規則:
- 同一個 `user_id + usage` 最好限制只能有一筆 `is_default = true`
- `label` 可為自由文字,不先做 enum
- 地址至少保留一筆,因此最後一筆不可刪除
目前進度:
- 地址簿資料模型、API、Web UI 已完成第一版
- `最後一筆不可刪除` 已實作
- 預設地址切換與資料結構已完成
- 畫面提示與驗證細節仍待整理
#### 5.3 資料治理原則
- 會員基本資料與地址簿屬於會員中心主資料,可供其他服務查詢,但寫入權限需嚴格控管。
- 會員本人可編輯自己的基本資料與地址簿。
- 其他服務預設只有讀取權限。
- 若其他服務需要代會員寫入,必須有額外 scope 與審計規則。
### 6. 會員資料 API 與 Auth Scope 規範
狀態:`scope 已落地,資源抽象化待續作`
#### 6.1 規劃目的
- 讓其他服務可透過 API 取得會員中心的基本資料與地址資料。
- 在一開始就把 scope 邊界定清楚,避免未來 profile API 無限制外放。
#### 6.2 建議 scopes
- `profile:basic.read`
- 讀取會員基本資料
- `profile:basic.write`
- 更新會員基本資料
- `profile:addresses.read`
- 讀取會員地址簿
- `profile:addresses.write`
- 新增、修改、刪除會員地址簿
- `profile:subscriptions.read`
- 讀取會員已訂閱電子報清單
- `profile:subscriptions.write`
- 透過已登入會員介面取消訂閱或調整會員自己的訂閱狀態
目前進度:
- `profile:*` scopes 已註冊並接上 policy
- current-user 與 by-email service API 已完成第一版
- audience / resource registry 仍為靜態,不在本輪完成範圍內
#### 6.3 API 邊界建議
- 其他服務 API
- 目前規劃以 service API 為主
- 只要 Auth 設定有授與對應 scope該服務即可存取對應資料
- 讀寫能力完全由 scope 控制
- 預設以最小權限授權,不因 client 類型自動放寬資料邊界
- 會員本人 UI / API
- 可讀寫自己的 profile 與地址
- 可查看自己的訂閱清單
- 可對自己的訂閱直接退訂,不需再次 email token 確認
- 管理後台:
- 可查詢會員資料,但是否可編輯應另行定義,不與一般會員本人編輯權混用
#### 6.4 與 OIDC 標準 scope 的關係
- 目前 `openid email profile` 中的 `profile` scope 太寬泛,不足以承載業務上需要的個資與地址簿授權控制。
- 建議保留 OIDC `profile` 作為基本 claims 用途,但業務資料改由自訂 scope 控制。
### 7. 會員端訂閱管理(我的訂閱)
狀態:`核心完成newsletter 補強待續作`
#### 7.1 目標
- 會員登入後,可集中查看自己目前已訂閱的電子報清單。
- 會員可直接從此介面取消訂閱,不需要再次透過 email token 驗證。
- 此流程與 email 內的一鍵退訂屬不同入口:
- email 退訂:適用未登入或直接從信件操作
- 會員中心退訂:適用已登入且已確認是本人
#### 7.2 UI 能力
- 顯示已訂閱清單:
- 所屬 tenant / 站台
- 電子報清單名稱
- 訂閱狀態
- 訂閱建立時間
- 最後更新時間
- 可執行:
- 直接取消訂閱
- 未來可擴充為偏好調整或重新訂閱
目前進度:
- 我的訂閱頁與 current-user 訂閱 API 已完成
- 會員登入後可直接退訂,不需再次 email token
- 進一步的 newsletter UI / 行為補強本輪刻意先跳過
#### 7.3 流程規則
- 已登入會員對自己的訂閱執行退訂時,不需 email token 二次確認。
- 系統仍需:
- 驗證 subscription 確實屬於目前登入會員
- 寫入 audit log
- 發送 `subscription.unsubscribed` 事件
- 若該 email 已在黑名單中,行為需與既有退訂規則一致。
- 即使已綁定 `user_id`,仍保留既有以 `list_id + email` 進行訂閱 / 退訂的 public 流程。
- 會員可重新訂閱已退訂的電子報,規則與既有 public subscribe 流程保持一致。
#### 7.4 與現行訂閱 API 的差異
- 現行 `/newsletter/preferences` 偏向以 `list_id + email` 操作,主要照顧跨站 API 邊界。
- 新的「我的訂閱」介面應改以登入者身份為主體,不再要求使用者自行輸入 email。
- 建議新增一組以 current user 為主體的 API而不是把既有未登入流程硬改成同一支。
### 7.5 訂閱識別原則
- Email 是訂閱領域的主 key。
- `user_id` 為已註冊會員與訂閱資料的關聯鍵,不取代 email 在訂閱流程中的角色。
- 因此系統同時保留:
- public flow`list_id + email`
- member flow`current user`
- 兩種入口最終都回到同一組 subscription 資料。
### 8. 會員中心作為外部服務的 Token / Auth 中心
狀態:`部分完成audience/scope 抽象化待續作`
#### 8.1 共通模型
- Send Engine 與 File Access 本質上是同一套模型:
- 由 Member Center 簽發 access token
- 外部服務以 Member Center JWKS 驗簽
- 依 `iss/aud/exp/scope/tenant_id` 做授權與租戶邊界控制
- 差異只在資源類型不同:
- Send Engine 偏向 service-to-service API 呼叫
- File Access 除了 upload 的 S2S token 外download 還需要 delegated short-lived token
#### 8.2 File Access 納入規劃
- 新增 OAuth client usage建議至少區分
- `file_api`
- 或更明確拆成 `file_upload_api` / `file_download_delegate`
- 新增 scopes
- `files:upload.write`
- `files:download.read`
- `files:download.delegate`
- `files:delete`
- `files:metadata.read`
- 新增 audience
- `file_access_api`
- 新增 delegated token 規則:
- 下載 token 必須短效
- 必須綁定 `tenant_id`
- 必須綁定 `file_id``object_key`
- 必須綁定 `method`
- 不可直接重用一般 S2S access token 給 client
#### 8.3 目標設計
- Auth 資源設定採 resource registry不再以一組一組 `Auth__XAudience` 擴充。
- 每個外部資源服務都以統一結構註冊:
- resource name
- audience
- scopes
- client usages
- 是否需要 `tenant_id`
- 是否允許 delegated token
- 新增資源服務時,只擴充 registry 與授權規則,不再修改硬編碼 audience 分支。
- 所有外部資料存取均以 scope 作為唯一授權依據。
#### 8.4 授權模型
- OAuth client usage 與 resource 綁定:
- `tenant_api` -> `member_center_api`
- `send_api` -> `send_engine_api`
- `file_api` -> `file_access_api`
- scope 用於細粒度權限控制。
- `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 驗證成功
- 忘記密碼申請
- 密碼重設成功
- 已登入修改密碼
- Google external login 綁定
- 角色變更
- 帳號停用 / 啟用
- profile 修改
- 地址簿新增 / 編輯 / 刪除 / 預設地址切換
- 會員端直接退訂
- 系統設定修改
- OAuth client 建立
- OAuth client secret 旋轉
- OAuth client secret 顯示一次、旋轉與治理能力視為既有基線;細節之後再檢討。
目前進度:
- 帳號寄信、驗證、重設密碼、修改密碼、註冊、external login 綁定、角色變更、帳號停用 / 啟用、profile、地址、會員端退訂、系統設定修改均已有實作
- OAuth client 建立與 secret 旋轉等治理細節仍待續作
### 10. Rate Limit 與防濫用
狀態:`部分完成`
- 下列入口必須有 rate limit
- login
- forgot password
- resend verification
- register
- public subscribe
- unsubscribe token 申請
- one-click unsubscribe token 申請
- lockout 與 rate limit 需能區分:
- 人類使用者登入
- service API token 申請
目前進度:
- 已完成 login / forgot password / resend verification / register / public subscribe / unsubscribe token 申請
- `one-click unsubscribe token` 申請仍待補
- 人類登入 flow 已有 lockoutservice API token flow 與更細觀測仍待續作
### 11. MFA 與非本期項目
狀態:`待續作`
- 在 Email 寄送能力完成後,可規劃 Email-based MFA / challenge 驗證。
- 其餘先明確列為未來可補項:
- consent / terms acceptance
- 資料匯出 / 刪除
- login history / device management
- 通知偏好中心
## 一次到位的實作範圍
1. 設定中心:
- SMTP
- Send Engine
- Auth 資源設定
- token lifetime
2. Identity 與通知:
- Email 驗證寄信
- 忘記密碼寄信
- 重送驗證信
3. 會員主資料:
- 基本資料
- 地址簿
- Profile UI / API
4. 訂閱管理:
- 我的訂閱頁
- current-user 型訂閱 API
- 直接退訂流程
5. 權限與角色:
- `superuser`
- `admin`
- `member` + verified / unverified
- 帳號管理與角色指派
6. Auth 中心:
- resource registry
- profile scopes
- file access scopes
- delegated token
7. 安全治理:
- audit log
- rate limit
- MFA 預留
8. 文件、測試與 installer 同步調整
目前整體狀態:
- `1`:部分完成
- `2`:核心完成,文案待續作
- `3`:核心完成
- `4`:核心完成,但本輪不再補強 newsletter refinement
- `5`:第一版完成
- `6`:先跳過 audience / scope 抽象化
- `7`:大致完成,仍有少量補強
- `8`:部分完成
## 影響範圍
### Web
- 新增設定畫面與表單。
- 新增帳號管理畫面。
- 調整註冊成功後導引與驗證提醒文案。
- 新增會員 profile 編輯頁、地址簿頁與我的訂閱頁。
### API
- 若設定畫面走 API需新增設定讀寫端點。
- 若帳號管理後台走 API需新增角色管理與帳號查詢端點。
- 若要支援 File Access需新增或擴充
- resource / audience mapping
- file scopes 註冊
- delegated download token issuing
- 需新增 current-user 型 profile / addresses / subscriptions API 與對應 scopes。
### Infrastructure
- 新增 SMTP sender 與設定存取服務。
- 調整帳號 provisioning 與角色管理服務。
- 抽出 token resource resolver / delegated token issuer避免 audience 與 scope 判斷散落在 controller。
- 新增會員基本資料與地址簿的資料模型與服務層。
### Installer
- 建立或提升 `superuser`
- 視命名策略決定是否保留 `add-admin`,或改名為更貼近語意的指令。
## 文件與實作同步原則
- 文件中的「已存在」代表已有 controller / route / view / 基礎邏輯,不代表整體功能已完成產品化。
- 本規劃完成後,應同步回寫:
- `docs/UI.md`
- `docs/FLOWS.md`
- `docs/INSTALL.md`
- `README.md`

View File

@ -107,6 +107,14 @@
- `newsletter:events.read` - `newsletter:events.read`
- `newsletter:events.write` - `newsletter:events.write`
- `newsletter:events.write.global` - `newsletter:events.write.global`
- 規劃新增 profile scopes
- `profile:basic.read`
- `profile:basic.write`
- `profile:addresses.read`
- `profile:addresses.write`
- `profile:subscriptions.read`
- `profile:subscriptions.write`
- profile 相關 API 以 service API 為主要整合模式,授權完全由 scope 控制
- 規劃新增 file access scopes - 規劃新增 file access scopes
- `files:upload.write` - `files:upload.write`
- `files:download.read` - `files:download.read`
@ -122,10 +130,12 @@
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token - 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
- Send Engine 建議以 JWKS 驗簽 JWTJWS並驗證 `scope/tenant_id/exp` - Send Engine 建議以 JWKS 驗簽 JWTJWS並驗證 `scope/tenant_id/exp`
- `iss``Auth:Issuer` 設定(例:`http://localhost:7850/` - `iss``Auth:Issuer` 設定(例:`http://localhost:7850/`
- `aud` 預設: - `aud` 由 Auth resource registry 決定,而非每個服務各自新增一組獨立設定
- Send Engine 流程:`send_engine_api`(可用 `Auth:SendEngineAudience` 覆寫) - 標準資源包含:
- Member Center API 流程:`member_center_api`(可用 `Auth:MemberCenterAudience` 覆寫) - Send Engine 流程:`send_engine_api`
- File access 流程:`file_access_api`(建議新增設定) - Member Center API 流程:`member_center_api`
- File access 流程:`file_access_api`
- `TokenController` 依 scope / usage 對照 resource registry 計算 `aud`
### File Access AuthA service / client / access agent ### File Access AuthA service / client / access agent
@ -160,6 +170,10 @@
- token 內檔案識別與實際 request 是否一致 - token 內檔案識別與實際 request 是否一致
- token 內 method 與實際 request 是否一致 - token 內 method 與實際 request 是否一致
補充:
- File Access 與 Send Engine 同屬「外部資源服務」,驗證模型一致
- 差異在於 File Access download token 為 delegated short-lived token而非一般 client credentials token
### 回寫原因碼Send Engine -> Member Center ### 回寫原因碼Send Engine -> Member Center
- `hard_bounce` - `hard_bounce`
- `soft_bounce_threshold` - `soft_bounce_threshold`

View File

@ -4,7 +4,8 @@
### 會員端 ### 會員端
- 註冊 / 登入 / 忘記密碼 / 修改密碼 - 註冊 / 登入 / 忘記密碼 / 修改密碼
- Email 驗證 - Email 驗證
- 個人資料Email 為主) - 個人資料(基本資料、聯絡方式、公司資訊)
- 收貨地址簿
- 訂閱管理(清單與偏好) - 訂閱管理(清單與偏好)
- 退訂(單一清單) - 退訂(單一清單)
- 連結外站(可選:回到來源站點) - 連結外站(可選:回到來源站點)
@ -16,6 +17,7 @@
- 訂閱查詢 / 匯出 - 訂閱查詢 / 匯出
- 審計紀錄查詢 - 審計紀錄查詢
- 系統設定安全策略、token 時效) - 系統設定安全策略、token 時效)
- Auth 資源設定resource / audience / scope / usage mapping
## 各站自建 UIAPI ## 各站自建 UIAPI
### 會員端 ### 會員端
@ -48,15 +50,21 @@
- UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...` - UC-07 訂閱確認double opt-in: `/newsletter/confirm?token=...`
- UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...` - UC-08 取消訂閱(單一清單): `/newsletter/unsubscribe?token=...`
- UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?list_id=...&email=...` - UC-09 訂閱偏好管理(登入後): `/newsletter/preferences?list_id=...&email=...`
- UC-10 會員資料查看: `/profile` - UC-10 會員資料查看 / 編輯: `/profile`
- UC-10.1 收貨地址簿管理: `/profile/addresses`
- UC-10.2 我的電子報訂閱: `/profile/subscriptions`
### 管理者端(統一 UI ### 管理者端(統一 UI
- UC-11 租戶管理: `/admin/tenants` - UC-11 租戶管理: `/admin/tenants`
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`UUID - UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`UUID
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `send_api` / `webhook_outbound` / `platform_service``platform_service` 可不指定 tenant - UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `send_api` / `webhook_outbound` / `platform_service` / `file_api``platform_service` 可不指定 tenant
- `redirect_uris``webhook_outbound` 需要;其餘 usage 不需要 - `redirect_uris``webhook_outbound` 需要;其餘 usage 不需要
- `tenant_api` / `send_api` / `platform_service` 強制 `client_type=confidential` - `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential`
- UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs` - UC-15 審計紀錄查詢: `/admin/audit-logs`
- UC-16 安全策略設定: `/admin/security` - UC-16 安全策略設定: `/admin/security`
- UC-16.1 Auth 資源設定:建議整併於 `/admin/security` 或其子頁籤,而非獨立新頁
- 管理 `Issuer` 顯示資訊與非敏感 Auth 設定
- 管理 resource registry例如 `member_center_api``send_engine_api``file_access_api`
- audience 設定建議放在現有 Auth / Security 設定畫面下,而不是放到 SMTP / Send Engine 整合設定頁

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,17 +16,20 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly MemberCenterDbContext _dbContext; private readonly MemberCenterDbContext _dbContext;
private readonly ISendEngineWebhookPublisher _webhookPublisher; private readonly ISendEngineWebhookPublisher _webhookPublisher;
private readonly IAuditLogWriter _auditLogWriter;
private readonly ILogger<AccountProvisioningService> _logger; private readonly ILogger<AccountProvisioningService> _logger;
public AccountProvisioningService( public AccountProvisioningService(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
MemberCenterDbContext dbContext, MemberCenterDbContext dbContext,
ISendEngineWebhookPublisher webhookPublisher, ISendEngineWebhookPublisher webhookPublisher,
IAuditLogWriter auditLogWriter,
ILogger<AccountProvisioningService> logger) ILogger<AccountProvisioningService> logger)
{ {
_userManager = userManager; _userManager = userManager;
_dbContext = dbContext; _dbContext = dbContext;
_webhookPublisher = webhookPublisher; _webhookPublisher = webhookPublisher;
_auditLogWriter = auditLogWriter;
_logger = logger; _logger = logger;
} }
@ -47,11 +50,28 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
return Failed(result); 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 var linkedSubscriptionsCount = await LinkSubscriptionsAndAuditAsync(user, "local_registration", new
{ {
email = normalizedEmail email = normalizedEmail
}); });
await _auditLogWriter.WriteAsync("user", user.Id, "account.registered", new
{
user_id = user.Id,
email = normalizedEmail,
registration_type = "local"
});
return new AccountProvisioningResult( return new AccountProvisioningResult(
true, true,
user.Id, user.Id,
@ -121,6 +141,16 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
return Failed(createResult); 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; createdUser = true;
} }
else if (emailVerified && !user.EmailConfirmed) else if (emailVerified && !user.EmailConfirmed)
@ -142,6 +172,14 @@ public sealed class AccountProvisioningService : IAccountProvisioningService
created_user = createdUser 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( return new AccountProvisioningResult(
true, true,
user.Id, user.Id,

View File

@ -22,7 +22,7 @@ public sealed class AuditLogService : IAuditLogService
.ToListAsync(); .ToListAsync();
return logs 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(); .ToList();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -54,7 +54,7 @@ var targetMigrationOption = new Option<string?>(
name: "--target", name: "--target",
description: "Target migration"); 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(connectionStringOption);
initCommand.AddOption(appsettingsOption); initCommand.AddOption(appsettingsOption);
initCommand.AddOption(noPromptOption); initCommand.AddOption(noPromptOption);
@ -87,19 +87,20 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
if (!noPrompt) if (!noPrompt)
{ {
adminEmail ??= Prompt("Admin email", "admin@example.com"); adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password"); adminPassword ??= PromptSecret("Superuser password");
} }
adminEmail ??= "admin@example.com"; adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword)) if (string.IsNullOrWhiteSpace(adminPassword))
{ {
Console.Error.WriteLine("Admin password is required."); Console.Error.WriteLine("Superuser password is required.");
return; return;
} }
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
await EnsureRoleAsync(roleManager, "superuser");
await EnsureRoleAsync(roleManager, "admin"); await EnsureRoleAsync(roleManager, "admin");
await EnsureRoleAsync(roleManager, "support"); 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); await SetInstalledFlagAsync(db);
@ -134,7 +135,8 @@ initCommand.SetHandler(async (string? connectionString, string? appsettings, boo
} }
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption); }, 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(connectionStringOption);
addAdminCommand.AddOption(appsettingsOption); addAdminCommand.AddOption(appsettingsOption);
addAdminCommand.AddOption(noPromptOption); addAdminCommand.AddOption(noPromptOption);
@ -151,14 +153,14 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
if (!noPrompt) if (!noPrompt)
{ {
adminEmail ??= Prompt("Admin email", "admin@example.com"); adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("Admin password"); adminPassword ??= PromptSecret("Superuser password");
} }
adminEmail ??= "admin@example.com"; adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword)) if (string.IsNullOrWhiteSpace(adminPassword))
{ {
Console.Error.WriteLine("Admin password is required."); Console.Error.WriteLine("Superuser password is required.");
return; return;
} }
@ -167,7 +169,7 @@ addAdminCommand.SetHandler(async (string? connectionString, string? appsettings,
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>(); var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>(); var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
await EnsureRoleAsync(roleManager, "admin"); await EnsureRoleAsync(roleManager, "superuser");
var admin = await userManager.FindByEmailAsync(adminEmail); var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null) 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); }, 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(connectionStringOption);
resetCommand.AddOption(appsettingsOption); resetCommand.AddOption(appsettingsOption);
resetCommand.AddOption(noPromptOption); resetCommand.AddOption(noPromptOption);
@ -212,14 +215,14 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
if (!noPrompt) if (!noPrompt)
{ {
adminEmail ??= Prompt("Admin email", "admin@example.com"); adminEmail ??= Prompt("Superuser email", "admin@example.com");
adminPassword ??= PromptSecret("New password"); adminPassword ??= PromptSecret("New password");
} }
adminEmail ??= "admin@example.com"; adminEmail ??= "admin@example.com";
if (string.IsNullOrWhiteSpace(adminPassword)) if (string.IsNullOrWhiteSpace(adminPassword))
{ {
Console.Error.WriteLine("Admin password is required."); Console.Error.WriteLine("Superuser password is required.");
return; return;
} }
@ -230,7 +233,7 @@ resetCommand.SetHandler(async (string? connectionString, string? appsettings, bo
var admin = await userManager.FindByEmailAsync(adminEmail); var admin = await userManager.FindByEmailAsync(adminEmail);
if (admin is null) if (admin is null)
{ {
Console.Error.WriteLine("Admin not found."); Console.Error.WriteLine("Superuser not found.");
return; return;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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