Compare commits
2 Commits
2cbef03ef7
...
c9c0396ad2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c0396ad2 | ||
|
|
5752d649e0 |
@ -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
|
||||||
|
|||||||
100
docs/DESIGN.md
100
docs/DESIGN.md
@ -13,11 +13,12 @@
|
|||||||
- OAuth2 + OIDC(Authorization Code + PKCE)
|
- OAuth2 + OIDC(Authorization 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` 查詢或操作
|
||||||
- 訂閱狀態同步目前採 webhook(event payload);queue 為後續可選擴充
|
- 訂閱狀態同步採 webhook(event payload);queue 可作為未來擴充選項
|
||||||
- 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
|
||||||
|
|
||||||
### 租戶端取 Token(Client Credentials)
|
### 租戶端取 Token(Client 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 驗簽 JWT(JWS)
|
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
||||||
- 驗簽通過後將 `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. 其他文件
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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) 建立 roles(admin, support)
|
3) 建立 roles(superuser, 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
784
docs/MEMBER_UPGRADE_PLAN.md
Normal 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:
|
||||||
|
- Web:login / register / forgot password / resend verification
|
||||||
|
- API:register / forgot password / resend verification
|
||||||
|
- Newsletter API:public subscribe / unsubscribe token
|
||||||
|
- password grant login 已改為走 `SignInManager.CheckPasswordSignInAsync(..., lockoutOnFailure: true)`,與 Web login 共用 lockout 行為
|
||||||
|
- 已完成 `superuser` / `admin` 權限模型第一版落地:
|
||||||
|
- `Admin` policy 已擴為接受 `admin` 與 `superuser`
|
||||||
|
- 新增 `Superuser` policy
|
||||||
|
- installer `init` / `add-superuser` 會建立或提升 `superuser`
|
||||||
|
- 舊指令別名 `add-admin` / `reset-admin-password` 仍可用
|
||||||
|
- 已新增管理後台帳號治理頁:
|
||||||
|
- `/admin/accounts`
|
||||||
|
- 支援查詢帳號、查看 email verified / role / disabled / last login
|
||||||
|
- 只有 `superuser` 可授予或移除 `admin`
|
||||||
|
- 只有 `superuser` 可停用或啟用帳號
|
||||||
|
- 已補帳號治理規則:
|
||||||
|
- `superuser` 帳號不可在管理 UI 中被降權、停用、刪除或強制變更密碼
|
||||||
|
- disabled 中的 `admin` 可保留 `admin` role
|
||||||
|
- `superuser` 可在管理 UI 強制重設非 `superuser` 帳號密碼
|
||||||
|
- 強制重設密碼後會更新 security stamp 並撤銷既有 OpenIddict authorization / token
|
||||||
|
- 已補帳號治理與生命週期 audit log:
|
||||||
|
- `account.registered`
|
||||||
|
- `account.external_login_linked`
|
||||||
|
- `account.password_changed`
|
||||||
|
- `account.role_changed`
|
||||||
|
- `account.disabled`
|
||||||
|
- `account.enabled`
|
||||||
|
- `system.security_settings_updated`
|
||||||
|
- `system.security_test_email_sent`
|
||||||
|
- 已補登入治理:
|
||||||
|
- Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at`
|
||||||
|
- disabled 帳號無法透過 Web login、external login、password grant 取得登入
|
||||||
|
- Web cookie 與 API authenticated request 會檢查 disabled 狀態
|
||||||
|
|
||||||
|
進行中:
|
||||||
|
- profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示
|
||||||
|
|
||||||
|
待續作:
|
||||||
|
- OAuth client usage 與 profile scopes 的最終授權矩陣仍偏靜態,尚未進到 resource registry / DB 驅動
|
||||||
|
- Auth resource registry 與 audience/scope 資料驅動化
|
||||||
|
- Email 驗證信 / 重設信的正式模板與文案優化
|
||||||
|
- rate limit 與 lockout 規則補齊:
|
||||||
|
- one-click unsubscribe token 申請
|
||||||
|
- 服務型 token flow 與人類登入 flow 的完整差異化治理
|
||||||
|
- 更細的風控觀測與後台設定化
|
||||||
|
- audit log 與 rate limit 尚未全面覆蓋所有規劃入口與治理事件
|
||||||
|
- `superuser` / `admin` 第一版已完成,後續待細化:
|
||||||
|
- 更細權限切分
|
||||||
|
- 是否需要更多治理角色
|
||||||
|
- 管理後台帳號治理功能補強:
|
||||||
|
- 更完整排序 / 篩選進一步細化
|
||||||
|
- 更細的操作確認與保護規則擴充
|
||||||
|
|
||||||
|
## 現況盤點
|
||||||
|
|
||||||
|
### 已存在
|
||||||
|
- `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 已有 lockout;service 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`
|
||||||
@ -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 驗簽 JWT(JWS),並驗證 `scope/tenant_id/exp`
|
- Send Engine 建議以 JWKS 驗簽 JWT(JWS),並驗證 `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 Auth(A service / client / access agent)
|
### File Access Auth(A 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`
|
||||||
|
|||||||
16
docs/UI.md
16
docs/UI.md
@ -4,7 +4,8 @@
|
|||||||
### 會員端
|
### 會員端
|
||||||
- 註冊 / 登入 / 忘記密碼 / 修改密碼
|
- 註冊 / 登入 / 忘記密碼 / 修改密碼
|
||||||
- Email 驗證
|
- Email 驗證
|
||||||
- 個人資料(Email 為主)
|
- 個人資料(基本資料、聯絡方式、公司資訊)
|
||||||
|
- 收貨地址簿
|
||||||
- 訂閱管理(清單與偏好)
|
- 訂閱管理(清單與偏好)
|
||||||
- 退訂(單一清單)
|
- 退訂(單一清單)
|
||||||
- 連結外站(可選:回到來源站點)
|
- 連結外站(可選:回到來源站點)
|
||||||
@ -16,6 +17,7 @@
|
|||||||
- 訂閱查詢 / 匯出
|
- 訂閱查詢 / 匯出
|
||||||
- 審計紀錄查詢
|
- 審計紀錄查詢
|
||||||
- 系統設定(安全策略、token 時效)
|
- 系統設定(安全策略、token 時效)
|
||||||
|
- Auth 資源設定(resource / audience / scope / usage mapping)
|
||||||
|
|
||||||
## 各站自建 UI(API)
|
## 各站自建 UI(API)
|
||||||
### 會員端
|
### 會員端
|
||||||
@ -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 整合設定頁
|
||||||
|
|||||||
34
src/MemberCenter.Api/Contracts/ProfileRequests.cs
Normal file
34
src/MemberCenter.Api/Contracts/ProfileRequests.cs
Normal 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);
|
||||||
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
using MemberCenter.Application.Models.Email;
|
||||||
|
|
||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAccountEmailTemplateService
|
||||||
|
{
|
||||||
|
EmailTemplate BuildVerificationEmail(string verifyUrl);
|
||||||
|
EmailTemplate BuildPasswordResetEmail(string resetUrl);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IAuditLogWriter
|
||||||
|
{
|
||||||
|
Task WriteAsync(string actorType, Guid? actorId, string action, object payload);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace MemberCenter.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IEmailSender
|
||||||
|
{
|
||||||
|
Task<int> SendAsync(string toEmail, string subject, string textBody, string? htmlBody = null);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/MemberCenter.Application/Abstractions/IProfileService.cs
Normal file
13
src/MemberCenter.Application/Abstractions/IProfileService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
14
src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs
Normal file
14
src/MemberCenter.Application/Models/Admin/SmtpSettingsDto.cs
Normal 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);
|
||||||
@ -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);
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace MemberCenter.Application.Models.Email;
|
||||||
|
|
||||||
|
public sealed record EmailTemplate(
|
||||||
|
string Subject,
|
||||||
|
string TextBody,
|
||||||
|
string HtmlBody);
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
25
src/MemberCenter.Domain/Entities/UserAddress.cs
Normal file
25
src/MemberCenter.Domain/Entities/UserAddress.cs
Normal 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;
|
||||||
|
}
|
||||||
21
src/MemberCenter.Domain/Entities/UserProfile.cs
Normal file
21
src/MemberCenter.Domain/Entities/UserProfile.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs
Normal file
29
src/MemberCenter.Infrastructure/Services/AuditLogWriter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
340
src/MemberCenter.Infrastructure/Services/ProfileService.cs
Normal file
340
src/MemberCenter.Infrastructure/Services/ProfileService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs
Normal file
72
src/MemberCenter.Infrastructure/Services/SmtpEmailSender.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
{
|
||||||
|
await _settingsService.SaveAsync(model, await GetCurrentUserIdAsync());
|
||||||
|
var saved = await _settingsService.GetAsync();
|
||||||
ViewData["Result"] = "Saved";
|
ViewData["Result"] = "Saved";
|
||||||
|
return View("Index", saved);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, ex.Message);
|
||||||
return View("Index", model);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>();
|
||||||
|
}
|
||||||
116
src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml
Normal file
116
src/MemberCenter.Web/Areas/Admin/Views/Accounts/Index.cshtml
Normal 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>
|
||||||
@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
53
src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs
Normal file
53
src/MemberCenter.Web/Models/Profile/AddressFormViewModel.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
52
src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs
Normal file
52
src/MemberCenter.Web/Models/Profile/ProfileViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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>();
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
<h1>Registration Complete</h1>
|
||||||
|
<p>Your account has been created. Please check your email for the verification link.</p>
|
||||||
85
src/MemberCenter.Web/Views/Profile/Addresses.cshtml
Normal file
85
src/MemberCenter.Web/Views/Profile/Addresses.cshtml
Normal 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>
|
||||||
@ -1,7 +1,59 @@
|
|||||||
@model MemberCenter.Infrastructure.Identity.ApplicationUser
|
@model MemberCenter.Web.Models.Profile.ProfileViewModel
|
||||||
|
|
||||||
<h1>Profile</h1>
|
<h1>Profile</h1>
|
||||||
|
@if (ViewData["Result"] is not null)
|
||||||
|
{
|
||||||
|
<p>@ViewData["Result"]</p>
|
||||||
|
}
|
||||||
|
@if (TempData["Result"] is string result)
|
||||||
|
{
|
||||||
|
<p>@result</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div asp-validation-summary="All"></div>
|
||||||
<p>Email: @Model.Email</p>
|
<p>Email: @Model.Email</p>
|
||||||
<p>Verified: @Model.EmailConfirmed</p>
|
<label asp-for="LastName"></label>
|
||||||
<p>Created: @Model.CreatedAt</p>
|
<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>
|
||||||
|
|||||||
43
src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml
Normal file
43
src/MemberCenter.Web/Views/Profile/Subscriptions.cshtml
Normal 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>
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user